mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 14:57:38 +00:00
两个独立的真实生产故障,都表现为"agent 答应了却没做到"、用户感觉
"它忘了刚说过的话"。按天统计 QQ 发送失败率:10-21 是 1.3%,10-23 涨到 34%。
## 故障一:流式并行 tool_call 参数串味
`internal/agent/api/provider.go` 累积 SSE 分片时用**切片下标**:
for i, tc := range ck.ToolCalls {
acc := accs[i] // ✗ 并行分片时 i 恒为 0
}
并行多工具时上游把每个 tool_call 的 arguments 分成多个 chunk 下发,
每个 chunk 的 ToolCalls 切片长度通常是 1 ⇒ 12 个并行调用的参数分片
**全部拼进 accs[0]**,其余 11 个拿到空串。
这个 bug 从 2026-08-25(618f51a)就存在。8-26 的 7a51c9b 号称
"按 JSON index 分桶,修复空参数调用",实际只加了 StreamIndex 字段和
诊断日志,**没改累积逻辑** —— 看起来修了,其实没修。
修法:`idx := tc.StreamIndex`(上游 index 才是归属依据)。
生产指纹:单次响应 12 个 tool_call 全部报 "has empty arguments",
6 小时 216 次,全部来自 deepseek-v4.1-flash 的并行调用。
## 故障二:meta 传对象被静默丢弃
"一 call 多发"(f850d75, 10-22)之后,模型对 meta 的传参形态不稳定:
有时传字符串 `"{\"user_id\":123}"`,有时直接传对象 `{"user_id":123}`。
`stringOrArray` 只认 string / []string / []interface{},遇对象返回 nil;
而 `parseOutputItems` 对空值的处理是**静默填空数组**:
if len(metas) == 0 { metas = make([]string, len(payloads)) } // 全填 ""
⇒ 插件收到 meta="" ⇒ 报 "meta 中需要 group_id 或 user_id 字段"。
10-23 一天 132 次。
修法:非字符串值**序列化保留**(marshalToJSONString),数组元素同理
(coerceToString)。内核不知道 meta 的语义,但它知道"模型给出了一个值",
不能当"没提供"处理。
## 判据(6 条,均经变异验证)
- SSE:并行分片各归各位;同一 tool_call 的多片按 index 拼接完整
- meta:对象被保留为 JSON 字符串;字符串原样透传;数组元素一一对应;
确实缺失时仍为空
变异验证:退回 `accs[i]` ⇒ 两条 SSE 判据红;退回不序列化 ⇒ meta 判据红
("meta 被静默丢弃(插件将报「需要 group_id 或 user_id」)")。
85 lines
3.4 KiB
Go
85 lines
3.4 KiB
Go
package api
|
||
|
||
import (
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// 流式并行 tool_call 的**分片归属**判据。
|
||
//
|
||
// 真实缺陷(2026-10-25 线上,2 小时 74 次 "has empty arguments"):
|
||
// 累积时用 `accs[i]`(切片下标)而不是 tc.StreamIndex(上游 index)。
|
||
//
|
||
// 并行多工具时上游把每个 tool_call 的 arguments **分成多个 chunk** 下发,
|
||
// 每个 chunk 的 ToolCalls 切片长度通常是 1(下标恒为 0)⇒ 12 个并行调用的
|
||
// 参数分片全部拼进 accs[0],其余 11 个拿到空串。
|
||
//
|
||
// 症状:模型侧"工具拿不到参数";用户侧"agent 忘了刚说要做什么"。
|
||
// 每次恰好 12 条告警(= 同一响应的 12 个 tool_call 全报空)就是它的指纹。
|
||
|
||
// sseWithToolCalls 构造一段 SSE 响应体:每个 tool_call 一个独立分片。
|
||
func sseWithToolCalls(entries [][2]string) string {
|
||
var b strings.Builder
|
||
for i, e := range entries {
|
||
sel := e[0]
|
||
args := e[1]
|
||
// 每个分片只含一个 tool_call,index 各不相同 —— 这正是并行多工具的真实形态
|
||
b.WriteString(`data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":` + sel +
|
||
`,"id":"call_` + sel + `","type":"function","function":{"name":"` + e[0] + `_tool","arguments":"` + args + `"}}]},"finish_reason":null}]}` + "\n\n")
|
||
_ = i
|
||
}
|
||
b.WriteString(`data: [DONE]` + "\n")
|
||
return b.String()
|
||
}
|
||
|
||
// 每个并行 tool_call 必须拿到**自己的**参数,不能全落到第一个。
|
||
func TestSSE_ParallelToolCallsKeepOwnArguments(t *testing.T) {
|
||
// 3 个并行调用,分片 index 0/1/2,各带不同参数
|
||
body := sseWithToolCalls([][2]string{
|
||
{"0", `{\"days\":7}`},
|
||
{"1", `{}`},
|
||
{"2", `{\"q\":\"x\"}`},
|
||
})
|
||
resp, ok := parseOpenAICompatibleSSEBody([]byte(body))
|
||
if !ok {
|
||
t.Skip("SSE 解析路径未启用(该 provider 可能不走流式)")
|
||
}
|
||
if len(resp.ToolCalls) != 3 {
|
||
t.Fatalf("应有 3 个 tool_call,实际 %d 个", len(resp.ToolCalls))
|
||
}
|
||
for _, tc := range resp.ToolCalls {
|
||
if argsLookDropped(tc.RawArguments) {
|
||
t.Fatalf("tool_call %q 的参数被判为空(分片归属错了):raw=%q",
|
||
tc.Name, tc.RawArguments)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 同一 tool_call 的 arguments 分成多片时,必须按 index 拼接完整。
|
||
func TestSSE_SplitArgumentsAccumulateByIndex(t *testing.T) {
|
||
// index 1 的参数分两片下发;index 0 只有一片。
|
||
// 若用切片下标累积,第二片会被错记到 accs[0]。
|
||
body := `data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_a","function":{"name":"a_tool","arguments":"{\"x\":1}"}}]}}]}` + "\n\n" +
|
||
`data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"id":"call_b","function":{"name":"b_tool","arguments":"{\"y\":"}}]}}]}` + "\n\n" +
|
||
`data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"2}"}}]}}]}` + "\n\n" +
|
||
`data: [DONE]` + "\n"
|
||
|
||
resp, ok := parseOpenAICompatibleSSEBody([]byte(body))
|
||
if !ok {
|
||
t.Skip("SSE 解析路径未启用")
|
||
}
|
||
if len(resp.ToolCalls) != 2 {
|
||
t.Fatalf("应有 2 个 tool_call,实际 %d 个", len(resp.ToolCalls))
|
||
}
|
||
byName := map[string]string{}
|
||
for _, tc := range resp.ToolCalls {
|
||
byName[tc.Name] = tc.RawArguments
|
||
}
|
||
if got := byName["a_tool"]; got != `{"x":1}` {
|
||
t.Fatalf("a_tool 参数错误(分片串了):%q", got)
|
||
}
|
||
if got := byName["b_tool"]; got != `{"y":2}` {
|
||
t.Fatalf("b_tool 参数未按 index 拼接:%q(期望 {\"y\":2})", got)
|
||
}
|
||
}
|