fix(provider): has empty arguments 误报 —— 零参数工具被当成参数丢失

## 现象

部署后生产日志出现 14 次:

    provider.go:414  [provider:llmsproxy] tool_call seq_list (...) has empty arguments

## 根因

原始响应里参数**完好**(从日志扒出来):

    "tool_calls":[{"function":{"arguments":"{}","name":"clawhubadapter_list"},...}]

诊断条件是 `len(tc.Arguments)==0 && tc.RawArguments==""`,而
`parseToolArguments("{}")` 走 string 分支 → `json.Unmarshal("{}", &m)`
成功且 `m != nil`(**非 nil 的空 map**)⇒ 返回空 map ⇒ 命中告警。

被点名的全是**零参数工具**(`seq_list` / `*_list` / `seq_help`,
它们的 `properties` 本来就是 `{}`)。

## 为什么必须修

不是"日志吵"。这条诊断的本职是抓「上游/适配器**真的**把参数丢了」,
真发生时会被这 14 次噪音淹没 —— **诊断日志失去信噪比就等于没有**。

## 修法

新增 `argsLookDropped(rawArgs)`,判 `RawArguments` **原文**而非解析后的 map:

- 空串 / 纯空白 ⇒ 上游没给 arguments 键 ⇒ 真丢
- 能解析成 JSON(哪怕是 `{}`)⇒ 上游确实回了参数 ⇒ 不报
- 解析失败(如半截 JSON)⇒ 参数本身是坏的 ⇒ 等同丢失

## 判据(3 条)

- `TestEmptyArgumentsDiagnosticIgnoresExplicitEmptyObject`  4 个子用例:
  `{}` / ` { } ` / 完全缺失 / 只有空白
- `TestArgsLookDroppedIgnoresNonEmpty`  非空参数一律不报
- `TestArgsLookDroppedEndToEnd`  用**日志里出现过的真实 body** 走
  `normalizeOpenAIToolCalls` 到判定的完整接缝 —— 单测过了但接缝不对
  只有端到端抓得到

写判据时我先用错了类型:拿 `apiToolCall`(**非流式**路径的结构)喂
`normalizeOpenAIToolCalls`,vet 直接报错才纠正为 `openAIToolCall`。
两套结构并存,很容易接错缝。

## 顺带记录:另一个告警不是内核缺陷

`重复申请 stage 锁`(5 次)经排查是**插件侧**问题,内核自愈机制工作正常:

- `proc_main.go.tmpl:1569` SDK 模板在每个 stage handler 入口**自动**调
  `stage.lock`;`lock.go:51` 锁**不可重入** ⇒ 同一次 `before_toolcall`
  被触发两次且首次未释放就命中
- 已排除 qq 业务代码:`beforeToolcall`(plugin.go:1303-1350)只有
  `ctx.Lock()`(SDK **数据**锁,与 proc stage 锁是两把锁)与纯本地调用,
  无任何再次触发 stage 的路径
- 成因在插件进程侧运行时(编译进 9月14日的 `plugin.bin`,**不随 homed 部署**)
- 内核 `stage.go:102-106` 的强制释放是**有意设计**("锁仲裁回内核"自愈,
  实验 9),避免后续插件死锁;`stages.go:258` 把错误收进 `ctx.Errors`
  不中断流程 ⇒ 那轮 212 秒正常跑完

两条结论都写进文档,避免以后有人当内核缺陷去修。

门禁:`-race` 通过,`go test ./internal/... ./cmd/...` 全绿。
This commit is contained in:
JianFeeeee
2026-09-27 21:18:32 +08:00
parent 20d357328b
commit bcd75d25fd
3 changed files with 196 additions and 2 deletions

View File

@ -0,0 +1,92 @@
package api
import (
"encoding/json"
"testing"
)
// `has empty arguments` 诊断日志在部署后的生产日志里出现了 14 次,
// 全部是误报。原始响应(从日志里扒出来的)参数**完好**:
//
// "tool_calls":[{"function":{"arguments":"{}","name":"clawhubadapter_list"},...}]
//
// 根因:`parseToolArguments("{}")` 走 string 分支 → `json.Unmarshal("{}", &m)`
// 成功且 `m != nil`(**非 nil 的空 map**)⇒ 返回空 map。而诊断条件是
// `len(args)==0 && RawArguments==""`,于是命中。
//
// 被点名的全是**零参数工具**(seq_list / *_list / seq_help,它们的
// `properties` 本来就是 `{}`)。
//
// ## 为什么要紧
//
// 不是"日志吵",是它**占用了本该报真问题的位置**:这条诊断存在的意义
// 是抓「上游/适配器真的把参数丢了」,真发生时会被这堆噪音淹没。
// 诊断日志一旦失去信噪比就等于没有。
func TestEmptyArgumentsDiagnosticIgnoresExplicitEmptyObject(t *testing.T) {
cases := []struct {
name string
rawArgs string
wantLog bool
}{
// 上游明确给了空对象 ⇒ 参数没丢,是零参数工具的正常形态
{"显式空对象 {}", "{}", false},
{"显式空对象带空格 { }", " { } ", false},
// 真正丢了参数:连 "{}" 都没有
{"完全缺失", "", true},
{"只有空白", " ", true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := argsLookDropped(c.rawArgs)
if got != c.wantLog {
t.Errorf("argsLookDropped(%q) = %v,期望 %v", c.rawArgs, got, c.wantLog)
}
})
}
}
// 上游给的是**非空**参数时,当然不能报。
func TestArgsLookDroppedIgnoresNonEmpty(t *testing.T) {
for _, raw := range []string{`{"a":1}`, `{"device_id":"x"}`, `{"path":"/tmp"}`} {
if argsLookDropped(raw) {
t.Errorf("argsLookDropped(%q) = true,非空参数不应被判为丢失", raw)
}
}
}
// 端到端:从真实的 tool_call 形态走到判定,确认零参数工具不报、
// 真丢失要报。这是防止"单测过了但接缝不对"。
func TestArgsLookDroppedEndToEnd(t *testing.T) {
// 日志里出现过的真实 body
const zeroParamBody = `{"choices":[{"message":{"tool_calls":[
{"function":{"arguments":"{}","name":"seq_list"},"id":"a","type":"function"}]}}]}`
const droppedBody = `{"choices":[{"message":{"tool_calls":[
{"function":{"name":"seq_list"},"id":"a","type":"function"}]}}]}`
// 用 openAIToolCall —— normalizeOpenAIToolCalls 的真实入参类型。
// (先前误用 apiToolCall,那是**非流式**路径的结构,接缝不对。)
normalize := func(body string) []ToolCall {
var resp struct {
Choices []struct {
Message struct {
ToolCalls []openAIToolCall `json:"tool_calls"`
} `json:"message"`
} `json:"choices"`
}
if err := json.Unmarshal([]byte(body), &resp); err != nil {
t.Fatalf("解析失败: %v", err)
}
return normalizeOpenAIToolCalls(resp.Choices[0].Message.ToolCalls)
}
for _, tc := range normalize(zeroParamBody) {
if argsLookDropped(tc.RawArguments) {
t.Errorf("零参数工具 %q 被误报为参数丢失", tc.Name)
}
}
for _, tc := range normalize(droppedBody) {
if !argsLookDropped(tc.RawArguments) {
t.Errorf("真丢失参数的 %q 未被报出 —— 这条诊断会失效", tc.Name)
}
}
}

View File

@ -408,9 +408,16 @@ func (p *LuaAdaptedProvider) Chat(ctx context.Context, req *CompletionRequest) (
return nil, fmt.Errorf("unmarshal unified response: %w (body: %s)", err, unifiedJSON)
}
// 诊断:tool_calls 存在但参数为空——上游/适配器丢参数,打印原始响应片段定位
// 诊断:tool_calls 存在但参数为空——上游/适配器丢参数,打印原始响应片段定位。
//
// ★ 判据是 argsLookDropped(tc.RawArguments),不是 len(tc.Arguments)==0。
//
// 零参数工具(seq_list / *_list / seq_help,properties 本来就是 {})
// 上游会明确回 "arguments":"{}"。旧判定把"空 map"当"丢了参数",
// 部署后误报 14 次 —— 而这条诊断的本职是抓**真丢参数**,
// 噪音会把真信号淹掉。详见 argsLookDropped。
for _, tc := range result.ToolCalls {
if len(tc.Arguments) == 0 && tc.RawArguments == "" {
if argsLookDropped(tc.RawArguments) {
log.Printf("[provider:%s] tool_call %s (%s) has empty arguments; raw body head: %s",
p.name, tc.Name, tc.ID, string(rawResp[:min(len(rawResp), 400)]))
}
@ -660,6 +667,31 @@ func normalizeStreamToolCall(tc openAIToolCall) ToolCall {
}
}
// argsLookDropped 报告「上游/适配器把 tool_call 的参数丢了」。
//
// 上游 JSON 里 arguments 有三种形态,只有第一种是真丢参数:
//
// "arguments":"{}" → 零参数工具的正常形态,不是丢失(生产误报 14 次)
// "arguments":"{\"a\":1}" → 正常
// 无 arguments 键 / 空串 → 真的丢了
//
// 刻意**不看**解析后的 Arguments map:`parseToolArguments("{}")` 返回的是
// 非 nil 的空 map,用 len()==0 判定必然误伤零参数工具。
func argsLookDropped(rawArgs string) bool {
trimmed := strings.TrimSpace(rawArgs)
// 上游没给 arguments 键时 Go 侧拿到空串;给空白也等价于没给。
if trimmed == "" {
return true
}
// 显式的空 JSON 对象:解析成功但没有字段 ⇒ 上游确实回了参数。
var probe map[string]json.RawMessage
if err := json.Unmarshal([]byte(trimmed), &probe); err == nil {
return false
}
// 解析失败(如 arguments 是一段半截 JSON)——参数本身就是坏的,等同于丢失。
return true
}
func parseToolArguments(v interface{}) map[string]interface{} {
switch x := v.(type) {
case nil:
@ -750,6 +782,7 @@ func pickFirstInt(a, b int) int {
}
return b
}
// streamHTTPClient 返回专用的流式 HTTP client(懒初始化)。
// SSE 长连接不能套整体超时(非流式 180s 会在长流中途报断),
// 只保留拨号/握手超时。