mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-28 21:33:05 +00:00
## 现象
部署后生产日志出现 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/...` 全绿。
93 lines
3.3 KiB
Go
93 lines
3.3 KiB
Go
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)
|
||
}
|
||
}
|
||
}
|