Capture OpenAI-compatible reasoning and replay it in assistant history #41
@@ -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
@@ -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
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user