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

@ -1,6 +1,7 @@
package core
import (
"encoding/json"
"fmt"
"log"
"strings"
@ -143,15 +144,56 @@ func stringOrArray(v interface{}) []string {
case []interface{}:
out := make([]string, 0, len(t))
for _, e := range t {
if s, ok := e.(string); ok {
out = append(out, s)
}
out = append(out, coerceToString(e))
}
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
}
// 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 {
channel := strings.TrimPrefix(tc.Name, "output_send__")
if channel == "" {