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
contentBuf.WriteString(ck.Content)
reasoningBuf.WriteString(ck.ReasoningContent)
for i, tc := range ck.ToolCalls {
acc := accs[i]
for _, tc := range ck.ToolCalls {
// ★ 必须用**上游的 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 {
acc = &sseAcc{}
accs[i] = acc
toolOrder = append(toolOrder, i)
accs[idx] = acc
toolOrder = append(toolOrder, idx)
}
if tc.ID != "" {
acc.id = tc.ID