Capture OpenAI-compatible reasoning and replay it in assistant history #41

Merged
weichen merged 1 commits from feature/quality-p2-i2 into main 2026-10-09 16:07:23 +00:00
5 changed files with 78 additions and 6 deletions
+1 -1
View File
@@ -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"`
+20 -1
View File
@@ -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 原样带回 <think> 块。没有思考块的
// 消息零改动——非思考模型的上下文不受影响。
content := m.Content
if think := replayThink(m); think != "" {
content = "<think>\n" + think + "\n</think>\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 {
+9 -1
View File
@@ -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])
}
+45
View File
@@ -0,0 +1,45 @@
package llm
import (
"strings"
"testing"
)
// 交错思考模型(MiniMax 等)要求历史 assistant 原样带回思考内容:
// SSE 把 reasoning_content 捕获进 Message.Thinking;序列化把它作为
// <think> 前缀回放。没有思考块的消息保持零改动。
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, "<think>\n先读材料。\n</think>") || !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)
}
}
+3 -3
View File
@@ -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 捕获(不含签名),随会话
// 转录持久化,请求序列化时按 <think> 块回放(见 client.go replayThink)。
Thinking []ThinkingBlock `json:"thinking,omitempty"`
StopReason StopReason `json:"stop_reason,omitempty"`
IsError bool `json:"is_error,omitempty"`