fix(llm): 参数解析失败不再丢弃完好字段(改错值格式,不是截断)

★ 上次修复误判了成因。真实根因(日志 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、不会把真截断"修好"。

真实日志样本 + 保守性 + 反伪造三组回归测试已钉死。
This commit is contained in:
JianFeeeee
2026-09-19 16:47:13 +08:00
parent 6ddef5e49f
commit 53e7106985
2 changed files with 104 additions and 2 deletions

View File

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