From 2722d7609534251076f4bcee5564f4b9b4f0c3c2 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 19 Sep 2026 13:49:21 +0800 Subject: [PATCH] =?UTF-8?q?fix(llm):=20max=5Ftokens=20=E6=88=AA=E6=96=AD?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E9=9D=99=E9=BB=98=E9=99=8D=E7=BA=A7=E6=88=90?= =?UTF-8?q?=E7=A9=BA=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 长参数工具调用(整段脚本/大 JSON)被 core.llm.max_tokens 从中间切断时, 上游回 finish_reason=length,而旧实现把这个信号整个丢掉:残缺 JSON 解析失败 后静默降级成空 map,工具只看到参数为空并报 'path is required'。模型因此完全 看不出真因,原样重试四遍、次次撞同一堵墙(2026-09-19 实测 4 次 files_write 失败)。 注:files_read 并未失败——是写挂之后模型反复重写把读卷进同一轮,看起来像两者都报错。 改法: - finish_reason=length 时不再静默降级,改为塞入 __truncated_error 指引, 告诉模型「参数被截断 + 请拆成多次调用/追加写 + 勿原样重试」; - executeToolCallInner 见到该标记即短路,不拿空参数去调工具; - 非截断的残缺 JSON 保持旧行为(避免把「厂商不回 finish_reason」误判成截断)。 回归测试 2 条钉死这两面。 --- internal/agent/core/process.go | 38 +++++++++- internal/agent/core/stream_accumulate_test.go | 71 ++++++++++++++++++- internal/agent/core/stream_index_test.go | 2 +- internal/agent/core/toolcall.go | 8 +++ 4 files changed, 114 insertions(+), 5 deletions(-) diff --git a/internal/agent/core/process.go b/internal/agent/core/process.go index e507d08..bda22bb 100644 --- a/internal/agent/core/process.go +++ b/internal/agent/core/process.go @@ -153,7 +153,7 @@ func chatStreamWithFallback(ctx context.Context, p agentAPI.Provider, req *agent return p.Chat(ctx, req) } - resp, accErr := accumulateStream(ctx, ch, a, channel) + resp, accErr := accumulateStream(ctx, ch, a, channel, req.MaxTokens) // 中断/超时取消必须保持取消语义传给调用方(与原 Chat() 行为一致: // 被 cancel 时丢弃已收内容返回 err),让 process() 的 continue 分支 @@ -197,9 +197,32 @@ type toolCallAcc struct { argsRaw strings.Builder } +// truncatedArgsError 把「参数被 MaxTokens 截断」变成模型能看懂的一句话。 +// +// 背景(实测 2026-09-19):core.llm.max_tokens=4096 会在长参数(整段脚本/ +// 大 JSON)写到一半时从中间切断,上游回 finish_reason="length"。旧实现把这个 +// 信号整个丢掉,残缺 JSON 解析失败后静默降级成空 map,工具只看到参数为空 +// (files_write → "path is required"),模型因此完全看不出是被截断,原样重试 +// 四遍、次次撞同一堵墙(日志里 4 次 files_write 失败即此)。 +// +// 这里改成**显式报错 + 可执行指引**:告诉模型参数不完整、要拆小或改分批写。 +// 调用方(executeToolCallInner)据此短路,不再拿空参数去调工具。 +func truncatedArgsError(name string, rawLen, maxTokens int) string { + return fmt.Sprintf( + "工具 %s 调用被截断:参数 JSON 不完整(收到 %d 字节),"+ + "原因是本轮流式输出达到了 max_tokens=%d 的上限(上游 finish_reason=length),"+ + "不是网络或工具的问题。请改用更小的参数重试:把长内容拆成多次调用"+ + "(例如先写文件的前半部分,再用追加/编辑的方式补后半部分),"+ + "或先用更少的字段完成本次调用。请勿原样重复上一次的调用。", + name, rawLen, maxTokens) +} + // accumulateStream 消费 chunk channel,累积为完整 CompletionResponse, // 同时发布增量事件。返回的 response 与非流式 Chat() 的返回等价。 -func accumulateStream(ctx context.Context, ch <-chan agentAPI.StreamChunk, a *Agent, channel string) (*agentAPI.CompletionResponse, error) { +// +// maxTokens 是本轮请求发送的输出上限,只参与错误文案(把「参数被截断」说成 +// 模型能执行的话),不参与解析逻辑。 +func accumulateStream(ctx context.Context, ch <-chan agentAPI.StreamChunk, a *Agent, channel string, maxTokens int) (*agentAPI.CompletionResponse, error) { resp := &agentAPI.CompletionResponse{ ToolCalls: make([]agentAPI.ToolCall, 0), } @@ -224,6 +247,14 @@ func accumulateStream(ctx context.Context, ch <-chan agentAPI.StreamChunk, a *Ag } else if raw == "" { log.Printf("[agent] stream tool_call %s (idx=%d) received NO argument fragments", acc.name, idx) } + // 被 MaxTokens 截断时**不要**静默降级成空参数:那会让工具报 + // "path is required" 这类与真因无关的错,模型据此重试只会再撞一次。 + // 改成把「参数不完整」原样交给模型,并附上可执行的收缩指引。 + if !argsOK && lastFinish == "length" { + log.Printf("[agent] stream tool_call %s (idx=%d) TRUNCATED by max_tokens=%d (%d bytes of args) — surfacing to model", + acc.name, idx, maxTokens, len(raw)) + args = map[string]interface{}{"__truncated_error": truncatedArgsError(acc.name, len(raw), maxTokens)} + } tc := agentAPI.ToolCall{ ID: acc.id, Name: acc.name, @@ -294,6 +325,9 @@ func accumulateStream(ctx context.Context, ch <-chan agentAPI.StreamChunk, a *Ag if ck.Done && ck.FinishReason != "" { lastFinish = ck.FinishReason + if lastFinish == "length" { + log.Printf("[agent] stream finished with finish_reason=length (output hit max_tokens) — tool args may be truncated") + } } if ck.Usage != nil { resp.TokenUsage = *ck.Usage diff --git a/internal/agent/core/stream_accumulate_test.go b/internal/agent/core/stream_accumulate_test.go index 8c73037..9684f2a 100644 --- a/internal/agent/core/stream_accumulate_test.go +++ b/internal/agent/core/stream_accumulate_test.go @@ -2,6 +2,7 @@ package core import ( "context" + "strings" "testing" agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api" @@ -26,7 +27,7 @@ func TestAccumulateStreamToolCalls(t *testing.T) { close(ch) }() - resp, err := accumulateStream(context.Background(), ch, nil, "cli") + resp, err := accumulateStream(context.Background(), ch, nil, "cli", 4096) if err != nil { t.Fatalf("accumulateStream: %v", err) } @@ -56,7 +57,7 @@ func TestAccumulateStreamContent(t *testing.T) { ch <- agentAPI.StreamChunk{Done: true, FinishReason: "stop"} close(ch) }() - resp, err := accumulateStream(context.Background(), ch, nil, "cli") + resp, err := accumulateStream(context.Background(), ch, nil, "cli", 4096) if err != nil { t.Fatalf("accumulateStream: %v", err) } @@ -67,3 +68,69 @@ func TestAccumulateStreamContent(t *testing.T) { t.Fatalf("reasoning: %q", resp.ReasoningContent) } } + +// 回归(2026-09-19 实测事故):长参数工具调用被 max_tokens 从中间截断时, +// 上游发 finish_reason="length"、参数 JSON 残缺。旧实现把残缺 JSON 静默降级成 +// 空 map,工具只报 "path is required",模型看不出真因、原样重试四次。 +// +// 本测试钉死:截断必须变成带指引的 __truncated_error,而不是空参数。 +func TestAccumulateStreamTruncatedArgsSurfaced(t *testing.T) { + ch := make(chan agentAPI.StreamChunk, 10) + go func() { + ch <- agentAPI.StreamChunk{ToolCalls: []agentAPI.ToolCall{ + {ID: "call_1", Name: "files_write", RawArguments: `{"path":"/tmp/a.py","content":"# -*- coding`}, + }} + // 参数写到一半被切断,随后到达 length 终止块 + ch <- agentAPI.StreamChunk{ToolCalls: []agentAPI.ToolCall{ + {RawArguments: `: utf-8 -*-\nimport openpyxl\nfor i in range(80):\n w`}, + }} + ch <- agentAPI.StreamChunk{Done: true, FinishReason: "length"} + close(ch) + }() + + resp, err := accumulateStream(context.Background(), ch, nil, "cli", 4096) + if err != nil { + t.Fatalf("accumulateStream: %v", err) + } + if len(resp.ToolCalls) != 1 { + t.Fatalf("want 1 tool call, got %d", len(resp.ToolCalls)) + } + msg, ok := resp.ToolCalls[0].Arguments["__truncated_error"].(string) + if !ok || msg == "" { + t.Fatalf("截断的参数必须带 __truncated_error,实际 Arguments=%v", resp.ToolCalls[0].Arguments) + } + // 指引必须可执行:说出真因(截断/max_tokens)并给出拆小方案 + for _, want := range []string{"截断", "max_tokens=4096", "拆成多次调用"} { + if !strings.Contains(msg, want) { + t.Errorf("指引缺少 %q:%s", want, msg) + } + } + // 截断时绝不能把残缺 JSON 解析出的空 map 当参数交出去 + if _, hasPath := resp.ToolCalls[0].Arguments["path"]; hasPath { + t.Error("截断参数不应残留任何可用字段(否则会以残缺参数执行)") + } +} + +// 非截断的残缺 JSON 保持旧行为(静默降级成空 map,由工具自己的必填校验报错): +// 这样不会把「厂商不回 finish_reason」的流也误判成截断。 +func TestAccumulateStreamInvalidArgsNotFlaggedAsTruncated(t *testing.T) { + ch := make(chan agentAPI.StreamChunk, 10) + go func() { + ch <- agentAPI.StreamChunk{ToolCalls: []agentAPI.ToolCall{ + {ID: "call_1", Name: "files_write", RawArguments: `{"path":`}, + }} + ch <- agentAPI.StreamChunk{Done: true, FinishReason: "tool_calls"} + close(ch) + }() + + resp, err := accumulateStream(context.Background(), ch, nil, "cli", 4096) + if err != nil { + t.Fatalf("accumulateStream: %v", err) + } + if len(resp.ToolCalls) != 1 { + t.Fatalf("want 1 tool call, got %d", len(resp.ToolCalls)) + } + if _, ok := resp.ToolCalls[0].Arguments["__truncated_error"]; ok { + t.Error("finish_reason=tool_calls 时不应标记为截断") + } +} diff --git a/internal/agent/core/stream_index_test.go b/internal/agent/core/stream_index_test.go index 2ca199c..1da9458 100644 --- a/internal/agent/core/stream_index_test.go +++ b/internal/agent/core/stream_index_test.go @@ -41,7 +41,7 @@ func TestAccumulateStreamParallelToolCallsByIndex(t *testing.T) { } close(ch) - resp, err := accumulateStream(ctx, ch, nil, "cli") + resp, err := accumulateStream(ctx, ch, nil, "cli", 4096) if err != nil { t.Fatalf("accumulateStream: %v", err) } diff --git a/internal/agent/core/toolcall.go b/internal/agent/core/toolcall.go index 9e7e4c5..10cb541 100644 --- a/internal/agent/core/toolcall.go +++ b/internal/agent/core/toolcall.go @@ -44,6 +44,14 @@ func (a *Agent) executeToolCall(tc agentAPI.ToolCall, channel string, turnScenes } func (a *Agent) executeToolCallInner(tc agentAPI.ToolCall, channel string, turnScenes []string) string { + // 参数被 max_tokens 截断(见 accumulateStream):**不要**拿着残缺/空参数去调工具。 + // 否则工具会报 "path is required" 这类与真因无关的错,模型看不出是截断, + // 只会原样重试(实测连续 4 次)。直接把可执行的指引交回模型。 + if msg, ok := tc.Arguments["__truncated_error"].(string); ok && msg != "" { + log.Printf("[agent] tool %s skipped: arguments were truncated by max_tokens", tc.Name) + return msg + } + switch { case tc.Name == "persona_set": return a.executePersonaTool(tc)