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:
JianFeeeee
2026-10-25 17:03:09 +08:00
parent 77f596fb07
commit c4731d896e
4 changed files with 246 additions and 7 deletions

View File

@ -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

View 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)
}
}

View File

@ -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 == "" {

View 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)
}
}