diff --git a/runtime/llm/anthropic.go b/runtime/llm/anthropic.go index 931156a..fa2f2ac 100644 --- a/runtime/llm/anthropic.go +++ b/runtime/llm/anthropic.go @@ -284,7 +284,7 @@ func parseAnthropicSSE(ctx context.Context, r io.Reader, out chan<- Event) { } case "content_block_start": var v struct { - Index int `json:"index"` + Index int `json:"index"` ContentBlock struct { Type string `json:"type"` ID string `json:"id"` diff --git a/runtime/llm/client.go b/runtime/llm/client.go index 54c29b5..9783bbc 100644 --- a/runtime/llm/client.go +++ b/runtime/llm/client.go @@ -121,6 +121,18 @@ func (c *Client) run(ctx context.Context, model string, messages []Message, tool } } +// replayThink 拼接一条 assistant 消息捕获到的全部思考块。 +func replayThink(m Message) string { + var b strings.Builder + for _, t := range m.Thinking { + if t.Thinking != "" { + b.WriteString(t.Thinking) + b.WriteString("\n") + } + } + return strings.TrimRight(b.String(), "\n") +} + func toProviderMessages(messages []Message) []map[string]any { out := make([]map[string]any, 0, len(messages)) for _, m := range messages { @@ -132,7 +144,14 @@ func toProviderMessages(messages []Message) []map[string]any { "content": m.Content, }) case RoleAssistant: - item := map[string]any{"role": "assistant", "content": m.Content} + // 回放捕获的思考(Message.Thinking 由 SSE 的 reasoning_content 捕获而来): + // 交错思考模型要求历史 assistant 原样带回 块。没有思考块的 + // 消息零改动——非思考模型的上下文不受影响。 + content := m.Content + if think := replayThink(m); think != "" { + content = "\n" + think + "\n\n\n" + content + } + item := map[string]any{"role": "assistant", "content": content} if len(m.ToolCalls) > 0 { calls := make([]map[string]any, 0, len(m.ToolCalls)) for _, tc := range m.ToolCalls { diff --git a/runtime/llm/sse.go b/runtime/llm/sse.go index 92a367b..82ee100 100644 --- a/runtime/llm/sse.go +++ b/runtime/llm/sse.go @@ -52,6 +52,7 @@ func ParseSSE(ctx context.Context, r io.Reader) <-chan Event { func emitParsed(ctx context.Context, r io.Reader, out chan<- Event) { var content strings.Builder + var reasoning strings.Builder calls := map[int]*ToolCall{} order := []int{} var usage Usage @@ -108,8 +109,12 @@ func emitParsed(ctx context.Context, r io.Reader, out chan<- Event) { } // reasoning_content(国产 OpenAI 兼容模型的思考链)与正文分流: // 只作为 reasoning_delta 事件外发,不计入 Message.Content(与 runner - // stripThinkBlocks"正文无 think"语义对齐)。 + // stripThinkBlocks"正文无 think"语义对齐)。同时累计进 reasoning—— + // 交错思考模型(MiniMax/DeepSeek R1 等)要求历史 assistant 原样带回 + // 思考内容,丢弃会降低后续轮质量;捕获进 Message.Thinking 随会话 + // 转录持久化,请求序列化侧按需回放。 if choice.Delta.Reasoning != "" { + reasoning.WriteString(choice.Delta.Reasoning) select { case <-ctx.Done(): emitAbort(out, content.String(), ctx.Err()) @@ -166,6 +171,9 @@ func emitParsed(ctx context.Context, r io.Reader, out chan<- Event) { order = nil } msg := Message{Role: RoleAssistant, Content: content.String(), StopReason: mapFinish(finished, len(order) > 0)} + if reasoning.Len() > 0 { + msg.Thinking = []ThinkingBlock{{Thinking: reasoning.String()}} + } for _, idx := range order { msg.ToolCalls = append(msg.ToolCalls, *calls[idx]) } diff --git a/runtime/llm/think_replay_test.go b/runtime/llm/think_replay_test.go new file mode 100644 index 0000000..b8c4cc7 --- /dev/null +++ b/runtime/llm/think_replay_test.go @@ -0,0 +1,45 @@ +package llm + +import ( + "strings" + "testing" +) + +// 交错思考模型(MiniMax 等)要求历史 assistant 原样带回思考内容: +// SSE 把 reasoning_content 捕获进 Message.Thinking;序列化把它作为 +// 前缀回放。没有思考块的消息保持零改动。 +func TestReasoningCaptureAndReplay(t *testing.T) { + // 回放侧。 + msgs := []Message{ + {Role: RoleUser, Content: "总结"}, + {Role: RoleAssistant, Content: "结论。", Thinking: []ThinkingBlock{{Thinking: "先读材料。"}}}, + {Role: RoleAssistant, Content: "没有思考的轮次。"}, + } + out := toProviderMessages(msgs) + if got := out[1]["content"].(string); !strings.HasPrefix(got, "\n先读材料。\n") || !strings.HasSuffix(got, "结论。") { + t.Fatalf("assistant replay malformed: %q", got) + } + if got := out[2]["content"].(string); got != "没有思考的轮次。" { + t.Fatalf("non-thinking assistant must be untouched: %q", got) + } + if _, ok := out[0]["tool_calls"]; ok { + t.Fatal("user messages carry no tool_calls") + } + // 捕获侧:直接构造 SSE 流。 + sse := "data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"思考\"}}]}\n\n" + + "data: {\"choices\":[{\"delta\":{\"content\":\"正文\"}}]}\n\n" + + "data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n" + + "data: [DONE]\n\n" + var end Event + for ev := range ParseSSE(t.Context(), strings.NewReader(sse)) { + if ev.Kind == KindMessageEnd { + end = ev + } + } + if end.Message.Content != "正文" { + t.Fatalf("content polluted by reasoning: %q", end.Message.Content) + } + if len(end.Message.Thinking) != 1 || end.Message.Thinking[0].Thinking != "思考" { + t.Fatalf("reasoning not captured: %+v", end.Message.Thinking) + } +} diff --git a/runtime/llm/types.go b/runtime/llm/types.go index 113bb8b..8d5ea83 100644 --- a/runtime/llm/types.go +++ b/runtime/llm/types.go @@ -52,9 +52,9 @@ type Message struct { Images []Image `json:"images,omitempty"` ToolCallID string `json:"tool_call_id,omitempty"` ToolCalls []ToolCall `json:"tool_calls,omitempty"` - // Thinking 是原生 Anthropic 路径的思考块(含签名),随 assistant 消息 - // 在请求里原样往返(Anthropic 要求思考块在内容块最前且签名不失)。 - // OpenAI 路径不写这个字段(reasoning 不落盘,见 sse.go 注释)。 + // Thinking 是思考块:Anthropic 原生路径含签名、在请求里原样往返; + // OpenAI 兼容路径由 SSE 的 reasoning_content 捕获(不含签名),随会话 + // 转录持久化,请求序列化时按 块回放(见 client.go replayThink)。 Thinking []ThinkingBlock `json:"thinking,omitempty"` StopReason StopReason `json:"stop_reason,omitempty"` IsError bool `json:"is_error,omitempty"`