Files
HomeAgent/docs/defect-qq-output-send-loop.md
JianFeeeee 27a7a3b439 chore(docs): 收编 QQ output_send 循环缺陷记录,标记已由 max_tool_turns 修复
仓库根目录的 problem.md(未跟踪)是一份 QQ `output_send` 回声/无限循环的
定位记录,状态写着「待修复」,但核心早已有轮次上限(core.agent.max_tool_turns,
默认 10,task.go 到达即强制收尾,测试 TestMaxToolTurns_CapsRunawayLoop)。
把它移进 docs/ 并更新状态,避免一份过期结论长期挂在根目录;同时删掉根目录的
临时基准脚本 tmp_fusion.py。
2026-09-14 15:35:31 +08:00

249 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# QQ `output_send` 回声/无限循环(核心侧缺陷) QQ `output_send` 回声/无限循环(核心侧缺陷,非插件)
> 状态:**已修复**(核心已具备轮次上限:`core.agent.max_tool_turns`,默认 10
> 在 `internal/agent/core/task.go` 到达上限即强制收尾;回归测试
> `TestMaxToolTurns_CapsRunawayLoop`。本文保留为缺陷定位过程记录。)
>
> 原始状态(修复前):**待修复**
> 影响Agent 单轮内每 ~10 秒调用一次 `output_send__qq`,持续数十分钟不结束(实测单轮 `1780624ms`80+ 次工具调用)
> 定位结论:**问题在核心(富回执 + 每轮重复追加同一条“继续”占位 + 无轮次上限QQ 插件侧已是最小回执,改插件无效**
---
## 1. 现象
生产日志(`/home/newqqagent`homed 运行期):
```
18:31:39 process.go:299: [agent] tool output_send__qq result: 已通过 [qq] 通道发送: map[status:sent]
18:31:47 process.go:299: [agent] tool output_send__qq result: 已通过 [qq] 通道发送: map[status:sent]
18:31:58 process.go:299: [agent] tool output_send__qq result: 已通过 [qq] 通道发送: map[status:sent]
18:32:06 ...
18:32:14 ...
(每 8~12 秒一条payload 长度各异387/237/246/203/252/270/254/231/258/227/188/227/200/209/155/284/212/173/198/242/218/191…
18:31:19 eventloop.go:426: [agent] text from qq → response (1780624ms, tools=[... 80+ 项 ...])
```
- 每条内容**都不同**,所以“相同参数才拦”的插件保险不会触发。
- 不是 webhook 回声15 分钟内只有 1 条真实入站中断)。
- 是模型每轮都收到“发送成功的富回执”,把它当成“继续下一步”的信号。
---
## 2. 根因链(核心侧,三层)
### 2.1 QQ 插件已经返回最小回执 —— 但被核心丢弃
`third_party/homeagent-sdk/example/qq/plugin.go``handleChannelOutput` 尾部):
```go
if sendErr != nil {
return nil, sendErr
}
// 成功:返回极简标记。不再回传 NapCat 原始响应(含 message_id 等)给模型,
// 避免模型把"发送成功"当成"上一步完成,继续下一步"的信号驱动循环。
return "ok", nil
```
插件返回的是字符串 `"ok"`
### 2.2 核心 proc 桥丢弃它并伪造 `status:sent`
`internal/plugin/proc/plugin.go:336``(*Plugin).invokeOutput`
```go
raw, err := p.proc.Call(MethodOutputInvoke, OutputInvokeParams{Channel: channel, Args: args})
if err != nil { return nil, err }
if len(raw) == 0 {
return map[string]interface{}{"status": "sent"}, nil
}
var res map[string]interface{}
if err := json.Unmarshal(raw, &res); err != nil {
return map[string]interface{}{"status": "sent"}, nil // ← "ok" 不是 JSON object落到这里
}
if _, ok := res["status"]; !ok {
res["status"] = "sent" // ← 再兜底
}
return res, nil
```
插件返回 `"ok"``json.Unmarshal``map[string]interface{}` 失败 → 核心合成 `{status: sent}`
**插件的返回值在这里被完全覆盖,所以只改插件永远修不掉回声。**
### 2.3 核心把这个富回执喂给模型
`internal/agent/core/output.go:81`HEAD / 部署中的 homed 行为):
```go
return fmt.Sprintf("已通过 [%s] 通道发送: %v", channel, result)
// → "已通过 [qq] 通道发送: map[status:sent]"
```
模型看到“发送成功 + 详情”后继续调用 `output_send__qq`,形成闭环。
### 2.4 核心没有工具轮次硬上限(放大器)
`core.agent.max_tool_turns` 只在配置层定义,**agent 循环里没有任何读取点**
```
internal/config/registry.go:551 set("core.agent.max_tool_turns", "10")
internal/config/registry.go:669 reg(ConfigDef{Key: "core.agent.max_tool_turns", ...})
$ grep -rn 'max_tool_turns\|MaxToolTurns' internal/agent/ → 无结果
```
`internal/agent/core/process.go:242` 的唯一终止条件是:
```go
if len(resp.ToolCalls) == 0 {
return resp.Content, toolsUsed, toolResults, nil
}
```
即:**模型不主动停,循环就永不结束**。`core.agent.max_tool_turns`(本机 DB 现为 `1000`)形同虚设。
### 2.5 每轮重复追加同一条 user 占位(“反复喂相同消息”的直接来源)
`internal/agent/core/process.go:77-82`,位置在 `for turn := 0; ; turn++` 循环的**顶部**
```go
for turn := 0; ; turn++ {
for _, interrupt := range a.drainInterrupts() { ... }
// 工具轮产出的 tool/assistant 消息作结尾会被 400 拒绝,故补一条 user 占位。
if last := msgs[len(msgs)-1]; last.Role == "assistant" || last.Role == "tool" {
msgs = append(msgs, agentAPI.Message{ // ← process.go:79
Role: "user",
Content: "请根据以上工具结果继续。",
})
}
...
}
```
`msgs``process.go:32` 在循环**外**创建,循环内只增不减:
- 每轮工具调用结束后,`msgs` 尾部必然是 `tool` 消息;
- 下一轮顶部判断成立,于是**再追加一条完全相同的** `请根据以上工具结果继续。`
- 不做替换、不做去重、不做裁剪(`ContextPolicy: prune` 只裁剪 `a.context`,不裁剪 `msgs`)。
跑 N 轮,模型收到的 prompt 里就叠了 N 条一模一样的“继续”指令。这才是“核心把前面相同消息反复喂给模型”的直接机制,也是把模型持续推向 `output_send` 的持续推力。
**预期行为**占位消息应当a仅在没有尾部 user 消息时补一条b补之前先移除上一条同类占位保持至多一条绝不能线性累积。
---
## 3. 现有未完成/未部署的修复
| 文件 | 状态 | 内容 |
| --- | --- | --- |
| `internal/agent/core/output.go:81` | **已改,未提交** (`M`) | `return fmt.Sprintf("已通过 [%s] 通道发送: %v", ...)``return "ok"`(含解释回声的注释) |
| `internal/plugin/proc/plugin.go:336` | **已改,未提交** (`M`) | 仍是伪造 `status:sent` 的版本,未处理非 map 返回值 |
| `third_party/homeagent-sdk/example/qq/plugin.go` | **已改,未提交** (`M`) | `handleChannelOutput` 返回 `"ok"` |
运行中的 `homed`**Sep 6 11:39** 构建的二进制,不含 `output.go` 的极简回执改动 → 仍回显富回执。
另外该二进制用旧 SDK 协议(`shmMagic` 直连,无 `unifiedMagic`),而 `third_party/homeagent-sdk` 仓库 HEAD 已升级到统一区域协议(`fc23612` 起)。**重建并部署 homed 时二者必须对齐**(见 §5
---
## 4. 建议修复
### 4.1 (必须)让模型只看到最小回执
**方案 A最小改动已在工作区**`internal/agent/core/output.go`
```go
// internal/agent/core/output.go:81
// 成功回执:只返回极简标记,不回传完整插件响应。
// 「已通过 [qq] 通道发送: map[status:sent message_id:xxx]」这类富回执
// 会驱动模型继续调用 output_send回声效应是 output loop 的根源之一。
return "ok"
```
**方案 B同时修掉 proc 桥的伪造)**`internal/plugin/proc/plugin.go:336`
不要对非 map 结果伪造 `status:sent`,保留插件真实返回;例如:
```go
if len(raw) == 0 {
return map[string]interface{}{"status": "ok"}, nil
}
var res map[string]interface{}
if err := json.Unmarshal(raw, &res); err != nil {
// 插件返回的是标量(如 "ok")——原样透传,不要伪造 status
var scalar interface{}
if err2 := json.Unmarshal(raw, &scalar); err2 == nil {
return scalar, nil
}
return map[string]interface{}{"status": "ok"}, nil
}
```
注意:`output.go` 仍需要 `status == "unconfirmed"/"queued"` 的判定,改成标量透传时该判定自然跳过(非 map语义正确。
### 4.2 (必须)工具循环硬上限
`internal/agent/core/process.go` 的工具循环里读取并强制 `core.agent.max_tool_turns`
- 位置:`for turn := 0; ; turn++ {` 循环内,执行工具前/每轮结束后检查。
- 语义:达到上限时追加一条系统消息(如 `[系统] 已达到最大工具轮次 N请立即总结并停止调用工具`),并终止循环返回当前内容,而不是继续下一轮。
- 至少要在 `turn > maxTurns` 时强制 `break`,避免模型不停调用。
### 4.3 建议QQ 插件侧保持最小回执
`third_party/homeagent-sdk/example/qq/plugin.go``return "ok", nil` 是正确的,保留即可。
**不要**再依赖插件侧修这个回声——见 §2.2。
### 4.4 (必须)修掉每轮重复追加的 user 占位
`internal/agent/core/process.go:77-82`。改为“至多保留一条”,例如:
```go
// 只在尾部是工具轮产物时补位;先移除上一条同类占位,避免线性累积。
if last := msgs[len(msgs)-1]; last.Role == "assistant" || last.Role == "tool" {
// 若尾部之上已经存在一条我们自己的占位,就不要重复追加。
if !isContinuationPlaceholder(msgs[len(msgs)-1]) {
msgs = append(msgs, agentAPI.Message{
Role: "user",
Content: continuationPlaceholder,
})
}
}
```
更稳妥的写法:在追加前从 `msgs` 尾部回扫,删除所有此前由本机制插入的占位,再追加一条。判定不要只靠字符串相等,建议给占位加一个可识别标记(例如 `internal:continuation`)或单独的 `NoMemory/Role` 约定,避免误删真实用户消息。
同时建议给 `msgs` 加长度/ token 上限(或定期裁剪历史),防止长任务把上下文堆爆(这正是 §2.4 无轮次上限的伴生问题)。
---
## 5. 部署前提与步骤
> ⚠️ 部署 homed 前必须先对齐 SDK 协议,否则所有子进程插件握手失败(`统一区域魔数不匹配`)。
1. **确认工具链协议与要部署的 homed 一致**
- 现状:`/usr/local/bin/homed` = 旧协议;`/usr/local/bin/plugindev` 已替换为旧协议版本(备份 `/usr/local/bin/plugindev.bak-20260910-174547`)。
- 若决定升级到统一区域协议,则需同时:升级 homed 二进制 + 用新 SDK`third_party/homeagent-sdk` HEAD重建全部插件。
- 若维持旧协议:用 `/usr/local/bin/plugindev`(旧)重建插件即可,不要用仓库 HEAD 的 `tools/plugindev` 直接 `go run`
2. **构建 homed**`go build ./...` 已验证通过;产出替换 `/usr/local/bin/homed`(按项目部署纪律:备份 → 原子替换)。
3. **重启**`systemctl restart homeagent.service`
4. **重建受影响的子进程插件**(协议一致时):至少 `qq`
---
## 6. 验收标准
修复后,发一条会触发回复的 QQ 消息,应满足:
1. 日志中 `output_send__qq` 的 tool result **不再包含** `已通过 [qq] 通道发送: map[status:sent]`
2. 单轮只发送 1 条(或模型明确决定的多条**不同**消息),**不出现每 ~10 秒一次的持续调用**
3. 当模型异常地持续调用工具时,日志出现达到 `core.agent.max_tool_turns` 的终止记录,且该轮在有限步内结束;
4. `eventloop.go:426``text from qq → response` 耗时应回落到正常量级(秒级~分钟级),不再是 30 分钟;
5. 抓取发往上游的请求(或用调试钩子 dump `req.Messages`),确认 `请根据以上工具结果继续。` 在整轮 prompt 中**至多出现一次**;修复前应为 N 条N=轮数),这正是 §2.5 的判据。
---
## 7. 相关背景(避免误修)
- QQ 插件的 `beforeToolcall` 循环保险只拦“参数完全相同的重复调用”(`max_duplicate_qq_send`**拦不住内容各异的循环**;本次循环每条内容都不同,所以保险未触发。这是设计使然,不是 bug。
- 15 分钟内仅 1 条真实 QQ 入站中断,说明**不是** webhook 把出站消息当入站回灌,**不是**插件回声。
- `internal/plugin/proc/plugin.go:122``invokeCleaner` 签名不匹配(此前导致 `go build` 失败)**已被修复**,当前 `go build ./...` 通过。