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 345d959e1e
commit 9e04196a77
3 changed files with 196 additions and 2 deletions

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 会在长流中途报断),
// 只保留拨号/握手超时。