fix(llm): max_tokens 截断不再静默降级成空参数

长参数工具调用(整段脚本/大 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 条钉死这两面。
This commit is contained in:
JianFeeeee
2026-09-19 13:49:21 +08:00
parent 01113664b4
commit 2722d76095
4 changed files with 114 additions and 5 deletions

View File

@ -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-19core.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

View File

@ -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 时不应标记为截断")
}
}

View File

@ -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)
}

View File

@ -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)