mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 06:47:29 +00:00
fix(agent): 流式并行工具参数串味 + meta 传对象被静默丢弃
两个独立的真实生产故障,都表现为"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」)")。
This commit is contained in:
@ -609,12 +609,24 @@ func parseOpenAICompatibleSSEBody(raw []byte) (*CompletionResponse, bool) {
|
|||||||
found = true
|
found = true
|
||||||
contentBuf.WriteString(ck.Content)
|
contentBuf.WriteString(ck.Content)
|
||||||
reasoningBuf.WriteString(ck.ReasoningContent)
|
reasoningBuf.WriteString(ck.ReasoningContent)
|
||||||
for i, tc := range ck.ToolCalls {
|
for _, tc := range ck.ToolCalls {
|
||||||
acc := accs[i]
|
// ★ 必须用**上游的 index**(StreamIndex),不能用切片下标。
|
||||||
|
//
|
||||||
|
// 流式协议里每个分片带 index 表明它属于第几个 tool_call。
|
||||||
|
// 并行多工具时上游会**分成多个 chunk**下发:每个 chunk 的
|
||||||
|
// ToolCalls 切片长度可能都是 1(下标恒为 0),若用切片下标累积,
|
||||||
|
// 12 个并行调用的参数分片会**全部拼进 accs[0]**,其余 11 个
|
||||||
|
// 拿到空串 ⇒ 被 argsLookDropped 判为"丢参数"。
|
||||||
|
//
|
||||||
|
// 线上实证(2026-10-25):2 小时内 74 次 "has empty arguments",
|
||||||
|
// 每次恰好 12 条(= 同一响应的 12 个并行 tool_call 全部报空),
|
||||||
|
// 模型侧表现为"工具拿不到参数",用户侧表现为"agent 忘了刚说要做什么"。
|
||||||
|
idx := tc.StreamIndex
|
||||||
|
acc := accs[idx]
|
||||||
if acc == nil {
|
if acc == nil {
|
||||||
acc = &sseAcc{}
|
acc = &sseAcc{}
|
||||||
accs[i] = acc
|
accs[idx] = acc
|
||||||
toolOrder = append(toolOrder, i)
|
toolOrder = append(toolOrder, idx)
|
||||||
}
|
}
|
||||||
if tc.ID != "" {
|
if tc.ID != "" {
|
||||||
acc.id = tc.ID
|
acc.id = tc.ID
|
||||||
|
|||||||
84
internal/agent/api/sse_toolcall_index_test.go
Normal file
84
internal/agent/api/sse_toolcall_index_test.go
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
package core
|
package core
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"strings"
|
"strings"
|
||||||
@ -143,15 +144,56 @@ func stringOrArray(v interface{}) []string {
|
|||||||
case []interface{}:
|
case []interface{}:
|
||||||
out := make([]string, 0, len(t))
|
out := make([]string, 0, len(t))
|
||||||
for _, e := range t {
|
for _, e := range t {
|
||||||
if s, ok := e.(string); ok {
|
out = append(out, coerceToString(e))
|
||||||
out = append(out, s)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
// ★ 模型把结构化值直接传成对象/数字(而非 JSON 字符串)时必须**序列化保留**,
|
||||||
|
// 不能返回 nil。
|
||||||
|
//
|
||||||
|
// 实测代价(2026-10-23 起,QQ 发送失败率 1.3% → 34%,132 次/天):
|
||||||
|
// 模型对 meta 的传参形态不稳定 —— 有时传 `"{\"user_id\":123}"`(字符串),
|
||||||
|
// 有时直接传 `{"user_id":123}`(对象)。后者在此返回 nil,而
|
||||||
|
// parseOutputItems 对 len(metas)==0 的处理是**静默填空数组**:
|
||||||
|
//
|
||||||
|
// if len(metas) == 0 { metas = make([]string, len(payloads)) }
|
||||||
|
//
|
||||||
|
// ⇒ 插件收到 meta="" ⇒ 报 "meta 中需要 group_id 或 user_id 字段"。
|
||||||
|
// 症状是"agent 答应了却没发到",而它完全不知道参数被吞了。
|
||||||
|
if t, ok := marshalToJSONString(v); ok {
|
||||||
|
return []string{t}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// coerceToString 把数组元素统一成字符串;结构化元素序列化为 JSON。
|
||||||
|
//
|
||||||
|
// 不做的话 `payload: [{"text":"a"}]` 之类会被静默丢弃(元素类型不是 string
|
||||||
|
// 就不 append),调用方只看到"少了几条",无从知道原因。
|
||||||
|
func coerceToString(v interface{}) string {
|
||||||
|
if s, ok := v.(string); ok {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
if t, ok := marshalToJSONString(v); ok {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// marshalToJSONString 把非字符串值序列化成 JSON 字符串。
|
||||||
|
//
|
||||||
|
// 返回 ok=false 表示 nil(即"确实没提供"),由调用方按缺失处理。
|
||||||
|
func marshalToJSONString(v interface{}) (string, bool) {
|
||||||
|
if v == nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return string(b), true
|
||||||
|
}
|
||||||
|
|
||||||
func (a *Agent) executeOutputSendTool(tc agentAPI.ToolCall) string {
|
func (a *Agent) executeOutputSendTool(tc agentAPI.ToolCall) string {
|
||||||
channel := strings.TrimPrefix(tc.Name, "output_send__")
|
channel := strings.TrimPrefix(tc.Name, "output_send__")
|
||||||
if channel == "" {
|
if channel == "" {
|
||||||
|
|||||||
101
internal/agent/core/output_meta_shape_test.go
Normal file
101
internal/agent/core/output_meta_shape_test.go
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
// meta / payload 传参形态的判据。
|
||||||
|
//
|
||||||
|
// ## 真实事故(2026-10-23 起,持续到 10-25)
|
||||||
|
//
|
||||||
|
// 生产实测:`output_send__qq` 失败率从 1.3% 涨到 34%(132 次/天)。
|
||||||
|
//
|
||||||
|
// 根因链:
|
||||||
|
//
|
||||||
|
// 模型对 meta 的传参形态不稳定:
|
||||||
|
// 有时传字符串 "{\"user_id\":123}" ← 能正常工作
|
||||||
|
// 有时直接传对象 {"user_id":123} ← 从 10-22 "一 call 多发" 之后变多
|
||||||
|
//
|
||||||
|
// stringOrArray 只认 string / []string / []interface{},遇到对象返回 nil
|
||||||
|
// ⇒ parseOutputItems 对 len(metas)==0 的处理是**静默填空数组**
|
||||||
|
// ⇒ 插件收到 meta="" ⇒ "meta 中需要 group_id 或 user_id 字段"
|
||||||
|
//
|
||||||
|
// 用户侧表现:agent 答应了却没发到,且它完全不知道参数被吞了
|
||||||
|
// (表现为"忘了刚说过的话")。
|
||||||
|
//
|
||||||
|
// ## 正确边界
|
||||||
|
//
|
||||||
|
// 内核**不知道** meta 的语义,但它知道"模型给出了一个值"。
|
||||||
|
// 该值不是字符串时必须**序列化保留**,不能当"没提供"处理。
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// 模型把 meta 传成对象时,必须序列化成 JSON 字符串保留下来。
|
||||||
|
func TestParseOutputItems_MetaAsObjectIsPreserved(t *testing.T) {
|
||||||
|
args := map[string]interface{}{
|
||||||
|
"payload": "你好",
|
||||||
|
"type": "text",
|
||||||
|
"meta": map[string]interface{}{"user_id": float64(2198972886)},
|
||||||
|
}
|
||||||
|
items, err := parseOutputItems(args)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("不该报错:%v", err)
|
||||||
|
}
|
||||||
|
if len(items) != 1 {
|
||||||
|
t.Fatalf("应有 1 条待发送项,实际 %d", len(items))
|
||||||
|
}
|
||||||
|
if items[0].meta == "" {
|
||||||
|
t.Fatal("meta 被静默丢弃(插件将报「需要 group_id 或 user_id」)")
|
||||||
|
}
|
||||||
|
// 必须是可被插件 json.Unmarshal 的 JSON 字符串
|
||||||
|
if items[0].meta != `{"user_id":2198972886}` {
|
||||||
|
t.Fatalf("meta 应序列化为 JSON 字符串,实际 %q", items[0].meta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 字符串形态必须**原样透传**(既有行为不能变)。
|
||||||
|
func TestParseOutputItems_MetaAsStringUnchanged(t *testing.T) {
|
||||||
|
const raw = `{"user_id":123}`
|
||||||
|
items, err := parseOutputItems(map[string]interface{}{
|
||||||
|
"payload": "你好",
|
||||||
|
"meta": raw,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("不该报错:%v", err)
|
||||||
|
}
|
||||||
|
if items[0].meta != raw {
|
||||||
|
t.Fatalf("字符串形态应原样透传,实际 %q", items[0].meta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// meta 数组里的对象元素也必须保留(一 call 多发时每条的 meta 可能不同)。
|
||||||
|
func TestParseOutputItems_MetaArrayWithObjects(t *testing.T) {
|
||||||
|
items, err := parseOutputItems(map[string]interface{}{
|
||||||
|
"payload": []interface{}{"a", "b"},
|
||||||
|
"meta": []interface{}{
|
||||||
|
map[string]interface{}{"user_id": float64(1)},
|
||||||
|
map[string]interface{}{"user_id": float64(2)},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("不该报错:%v", err)
|
||||||
|
}
|
||||||
|
if len(items) != 2 {
|
||||||
|
t.Fatalf("应有 2 条,实际 %d", len(items))
|
||||||
|
}
|
||||||
|
for i, it := range items {
|
||||||
|
if it.meta == "" {
|
||||||
|
t.Fatalf("第 %d 条的 meta 被丢弃", i+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if items[0].meta == items[1].meta {
|
||||||
|
t.Fatal("两条的 meta 不应相同(一一对应关系丢失)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确实没提供 meta 时,仍按"缺失"处理(空字符串),不能凭空造值。
|
||||||
|
func TestParseOutputItems_MissingMetaStaysEmpty(t *testing.T) {
|
||||||
|
items, err := parseOutputItems(map[string]interface{}{"payload": "你好"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("不该报错:%v", err)
|
||||||
|
}
|
||||||
|
if items[0].meta != "" {
|
||||||
|
t.Fatalf("未提供 meta 时应为空,实际 %q", items[0].meta)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user