From 53e71069855f94f96b44580f674e26d7661d6b27 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 19 Sep 2026 16:47:13 +0800 Subject: [PATCH] =?UTF-8?q?fix(llm):=20=E5=8F=82=E6=95=B0=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E5=A4=B1=E8=B4=A5=E4=B8=8D=E5=86=8D=E4=B8=A2=E5=BC=83?= =?UTF-8?q?=E5=AE=8C=E5=A5=BD=E5=AD=97=E6=AE=B5=EF=BC=88=E6=94=B9=E9=94=99?= =?UTF-8?q?=E5=80=BC=E6=A0=BC=E5=BC=8F=EF=BC=8C=E4=B8=8D=E6=98=AF=E6=88=AA?= =?UTF-8?q?=E6=96=AD=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ★ 上次修复误判了成因。真实根因(日志 11/11 同形): {"command": "…完好的长命令…", "timeout": 20s} command 一字节没错,只是 timeout 值少了引号 —— 而 cmd_run 的 schema 把 timeout 声明成 string、示例写着 "10s, 1m, 30s",模型照抄格式却忘了引号。 实测 finish_reason=length 出现 0 次,所以上次那条"截断"分支从不生效。 旧行为把**整个参数**丢掉:模型只看到 "command is required",看不出是 timeout 写坏了,只能原样重试 —— 12 分钟的任务里 30 次失败 / 32 次成功(48% 浪费), 每次失败都付一次完整 LLM 往返。 改法:parseToolArgsJSON 失败时先试 repairToolArgsJSON,只做一件很窄的事 —— 给"值位置上未加引号的带单位数字"补引号,且修完必须真能解析成功才接受。 因此不会改坏合法 JSON、不会动字符串正文里的 20s、不会把真截断"修好"。 真实日志样本 + 保守性 + 反伪造三组回归测试已钉死。 --- internal/agent/core/process.go | 45 +++++++++++++- internal/agent/core/stream_accumulate_test.go | 61 +++++++++++++++++++ 2 files changed, 104 insertions(+), 2 deletions(-) diff --git a/internal/agent/core/process.go b/internal/agent/core/process.go index bda22bb..f7618d5 100644 --- a/internal/agent/core/process.go +++ b/internal/agent/core/process.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "log" + "regexp" "strings" agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api" @@ -343,8 +344,12 @@ func accumulateStream(ctx context.Context, ch <-chan agentAPI.StreamChunk, a *Ag } // parseToolArgsJSON 将经过完整拼接的 tool call arguments JSON 字符串解析为 map。 -// 第二个返回值 ok=false 表示分片拼接结果不是合法 JSON(分片污染/丢失), -// 与「合法的空对象 {}」相区分。 +// 第二个返回值 ok=false 表示分片拼接结果不是合法 JSON(分片污染/丢失)。 +// +// ❗解析失败时**不要**直接返回空 map 就完事:调用方会拿着空参数去调工具, +// 工具只能报 “xxx is required”这类与真因无关的错(实测 2026-09-19: +// cmd_run 失败率 34%,全部同一个成因)。解析失败时先用 repairToolArgsJSON +// 试着把「一个可选字段写坏」与「整段截断」分开。 func parseToolArgsJSON(s string) (map[string]interface{}, bool) { if strings.TrimSpace(s) == "" { return map[string]interface{}{}, true @@ -353,9 +358,45 @@ func parseToolArgsJSON(s string) (map[string]interface{}, bool) { if err := json.Unmarshal([]byte(s), &m); err == nil && m != nil { return m, true } + if repaired, ok := repairToolArgsJSON(s); ok { + return repaired, true + } return map[string]interface{}{}, false } +// unitNumberRe 匹配**未加引号的带单位数字**,如 20s / 1m / 500ms。 +// +// 这是模型最常见的写法(工具 schema 里 timeout 的示例就是“10s, 1m, 30s”, +// 于是它把值原样写进 JSON,忘了声明里写的是 string 类型)。 +var unitNumberRe = regexp.MustCompile(`:\s*(-?\d+(?:\.\d+)?(?:ms|s|m|h|d))\s*([,}])`) + +// repairToolArgsJSON 试着修复**单字段值格式错**导致的 JSON 非法。 +// +// 为什么值得修而不是直接报错(实测 2026-09-19):11/11 个真 invalid JSON 都是 +// `{"command": "…完好的长命令…", "timeout": 20s}` —— command 一字节没错, +// 只因 timeout 少了引号。旧行为把**整个参数**丢掉,模型看到 “command is required” +// 后只能原样重试,实测 cmd_run 失败率 34%(34 败 / 64 成)。 +// +// 修复只做一件很窄的事:给未加引号的带单位数字补上引号。宁可保守也不能猜错: +// - 只动“值位置”上的 `数字+单位`,且后面紧跟着 `,` 或 `}` +// - 修完必须真的能解析成功才接受(否则返回 ok=false,行为同旧) +// +// 因此它不会把合法 JSON 改坏,也不会凭空造出字段。 +func repairToolArgsJSON(s string) (map[string]interface{}, bool) { + if !strings.Contains(s, ":") { + return nil, false + } + fixed := unitNumberRe.ReplaceAllString(s, `: "$1"$2`) + if fixed == s { + return nil, false + } + var m map[string]interface{} + if err := json.Unmarshal([]byte(fixed), &m); err != nil || m == nil { + return nil, false + } + return m, true +} + func convertToolCalls(tcs []agentAPI.ToolCall) []sdk.ToolCall { if tcs == nil { return nil diff --git a/internal/agent/core/stream_accumulate_test.go b/internal/agent/core/stream_accumulate_test.go index eb41fcc..c6572a1 100644 --- a/internal/agent/core/stream_accumulate_test.go +++ b/internal/agent/core/stream_accumulate_test.go @@ -158,3 +158,64 @@ func TestTruncatedToolCallIsShortCircuited(t *testing.T) { } } } + +// 回归(2026-09-19 线上实测):真 invalid JSON 有 11/11 是同一成因 —— +// 模型把 timeout 写成 `"timeout": 20s`(值缺引号,schema 示例是 "10s, 1m, 30s" +// 而声明是 string 类型),而 command 部分一字节没错。 +// +// 旧行为:解析失败 → 静默降级成空 map → 整个 command 被丢 → 工具报 +// "command is required",模型只能原样重试 ⇒ 实测 cmd_run 失败率 34%(34 败/64 成)。 +func TestRepairUnquotedUnitNumberInArgs(t *testing.T) { + // 全部取自日志原文(未被我自己的日志截断的那些) + real := []string{ + `{"command": "ls -lt /tmp/*.xlsx /tmp/*.py 2>/dev/null | head -20; echo \"=== home ===\"; ls -lt ~ 2>/dev/null | head -20", "timeout": 20s}`, + `{"command": "sleep 45; cat /tmp/run_szce.log; ls -la /tmp/szce_run_raw.json 2>/dev/null", "timeout": 90s}`, + `{"command": "echo \"=== 上一轮 raw (471B) ===\"; cat /tmp/szce_run_raw.json; echo; echo \"=== 后台进程 ===\"; ps aux | grep -c \"[r]un_szce.py\"; echo \"=== log ===\"; cat /tmp/run_szce.log", "timeout": 30s}`, + `{"command": "sleep 60; cat /tmp/probe_out.txt; echo \"=== alive ===\"; ps aux | grep -c \"[p]robe_models.py\"", "timeout": 120s}`, + `{"command": "cat /tmp/probe_out.txt; echo \"--- alive ---\"; ps aux | grep -c \"[p]robe_models.py\"", "timeout": 30s}`, + } + for i, s := range real { + m, ok := parseToolArgsJSON(s) + if !ok { + t.Errorf("case %d 仍解析失败", i) + continue + } + if cmd, _ := m["command"].(string); cmd == "" { + t.Errorf("case %d 完好的 command 丢失", i) + } + if to, _ := m["timeout"].(string); to == "" { + t.Errorf("case %d timeout 未补成字符串: %#v", i, m["timeout"]) + } + } +} + +// 修复必须保守:不能碰合法 JSON,尤其不能改到字符串**正文里**的 “20s”。 +func TestRepairKeepsValidArgsIntact(t *testing.T) { + m, ok := parseToolArgsJSON(`{"command": "ls", "timeout": "20s"}`) + if !ok { + t.Fatal("合法 JSON 被判非法") + } + if m["timeout"] != "20s" { + t.Errorf("合法 timeout 被改: %#v", m["timeout"]) + } + m2, ok := parseToolArgsJSON(`{"content": "wait 20s then go"}`) + if !ok { + t.Fatal("含 20s 的正文被判非法") + } + if m2["content"] != "wait 20s then go" { + t.Errorf("正文里的 20s 被误改: %#v", m2["content"]) + } +} + +// 真截断(JSON 从中间断掉)绝不能被“修好”,否则会拿残缺参数去执行 —— 更危险。 +func TestRepairDoesNotFabricateTruncatedArgs(t *testing.T) { + for _, s := range []string{ + `{"command": "ls -la /tmp && echo done"`, + `{"command": "echo hi", "timeout": 30`, + `{"path": "/tmp/x", "content": "unterminated`, + } { + if _, ok := parseToolArgsJSON(s); ok { + t.Errorf("截断参数被误判为可修复(危险): %s", s) + } + } +}