pi agent 插件开发实录:官方文档没说的 7 次配额排查 —— pi-wechat-assistant插件改造的踩坑记录
■「前面发了几条,然后微信就不动了。 问号发过去也没回应。 agent 是活着还是在睡觉?」
■引子:从能聊,到聊不下去
上一篇文章pi agent 插件开发实录:改造 pi-wechat-assistant,让工具调用在微信里看得见,问卷不再卡死,我们把一个叫 pi-wechat-assistant 的插件装好。
目的是让 pi agent 跑任务时, 我能在手机微信里随时看进展。
最初几天用着挺顺。
agent 接到了报告生成任务, 一条条章节往 ClawBot 里推。 我站在地铁上刷手机, 就能看到 Conway Automaton 的章节一篇篇落下来。
那阵子觉得这事终于成了。
直到有一天, 它发到第 7 条就停了。
不是网络问题。
不是 token 过期。
不是插件崩了。
agent 自己也没报错。
它就是……不发了。
我盯着 ClawBot 上的「对方正在输入」看了 30 秒, 又等了 5 分钟。 还是不动。
然后我发了个「测试」过去。 7 条新消息又来了。 然后又停了。
这就是这篇文章想讲的事。
■一、现象:第 7 条之后就没动静
1.1 上一轮解决了什么
上一篇《问卷不再卡死》一文里讲过: emoji、零宽字符、控制字符、超过 1900 字节的 UTF-8 内容, 会被微信端处理异常截断。
改完之后那阵子用着挺好。
agent 的长报告能稳定送达。
1.2 新的卡点
但用着用着发现一个怪事。
「你的 Conway Automaton 13 章报告,我微信端只看到前 7 章。 后面的章节没出现,但 ClawBot 没有显示错误。」
打开 ~/.pi/agent/wechat-assistant/debug.log 看真实发送日志:
[01:53:00] [SENDTEXT] len=800 chunk #1 → OK[01:53:00] [SENDTEXT] len=800 chunk #2 → OK[01:53:00] [SENDTEXT] len=800 chunk #3 → OK[01:53:00] [SENDTEXT] len=800 chunk #4 → OK[01:53:00] [SENDTEXT] len=800 chunk #5 → OK[01:53:00] [SENDTEXT] len=800 chunk #6 → OK[01:53:00] [SENDTEXT] len=800 chunk #7 → OK[01:53:00] [SENDTEXT] len=800 chunk #8 → [SENDTEXT-ERR] ret=-2 "prepare failed"[01:53:00] [SENDTEXT] len=800 chunk #9 → ERR几个细节很怪:
•错误都是 ret: -2, errmsg: "prepare failed"•第 8 条开始全 ERR,但 agent 端没异常抛出•用户消息进来后 context_token 刷新,但 sendmessage 仍然 ERR第一次见这种错的人会怀疑: 是不是协议不对?是不是 token 过期?是不是消息太长?
直觉反应是「上下文过期」。 让用户在微信里随便发一个「.」刷新 context_token,再让 agent 重发。 结果——前 7 条能正常发。 但第 8 条又 ERR。
换 token 没用,错误码完全相同。 说明问题不在 token 上。
■二、查文档:iLink 协议里 -2 是什么意思
2.1 官方协议
翻了 https://www.wechatbot.dev/zh/protocol:
| |
|---|
/ilink/bot/getupdates | |
/ilink/bot/sendmessage | |
/ilink/bot/sendtyping | |
错误码表:
•ret: -14 = session 过期,需要重新登录2.2 腾讯云开发者文章
另一份 https://cloud.tencent.com/developer/article/2651968 描述了buildHeaders 必须包含:
•AuthorizationType: 'ilink_bot_token'它没解释 -2 的具体含义, 但展示了协议合规的正确实现。
2.3 CSDN 排查文章
第三份 https://blog.csdn.net/riyuexinzhu/article/details/161588596 提到:
「ret -2 可能是 stale context_token 或者真实的 rate limit, 需要靠触发模式区分」
但这份资料没说明触发模式具体长什么样。
2.4 三份资料的矛盾
没有一个文档明确说:「-2 + HTTP 200」= 配额耗尽。
■三、排查:先看 fork 自己的代码
3.1 fork 协议合规检查
打开 src/wechat-assistant/src/api.ts:
const CHANNEL_VERSION = '1.0.0'; // 官方要求 2.0.0const WECHAT_UIN = crypto.randomBytes(4).readUInt32BE(0).toString('base64'); // 模块加载时算一次两个明确的协议违规:
•channel_version 写成 '1.0.0',协议要求 '2.0.0'•X-WECHAT-UIN 模块级 IIFE 算一次,所有请求复用同一个值3.2 第一次修:协议合规
按官方协议 + 腾讯云示例修复:
- const CHANNEL_VERSION = '1.0.0';+ const CHANNEL_VERSION = '2.0.0';- const WECHAT_UIN = crypto.randomBytes(4).readUInt32BE(0).toString('base64');+ function generateWechatUin(): string {+ return crypto.randomBytes(4).readUInt32BE(0).toString('base64');+ // buildHeaders 内调用 generateWechatUin(),每请求一个新值实测结果: 协议合规后,错误模式没有任何变化。 还是第 8 条开始 ERR,错误码还是 -2。
这说明协议合规不是根因。
■四、现象分析:纯靠观察找规律
4.1 观察维度
把每次 sendmessage ERR 的时间、payload、token 记录下来, 按多个维度做关联分析:
| |
|---|
| 第 7 条到第 8 条间隔 1 秒,前 7 条间隔 5 秒 |
| |
| |
| |
| {"ret":-2,"errmsg":"prepare failed"} |
| |
| 让用户发"再试一次",刷新 token,重发立即成功 7 条 |
4.2 关键规律
每轮成功的 sendmessage 数量恒定为 7 条 + 每轮成功的窗口由用户入站消息触发刷新。
这个规律与协议文档不符:
•协议说 rate limit 应该返回 HTTP 4xx/5xx4.3 推断
-2 在我们这个 bot 账号上 = sendmessage 端点的账号级配额。
不是协议字面意义上的「参数错误」。
但这个判断只是推断,需要进一步测试验证。
■五、测试:把配额曲线画出来
5.1 实验 1 — 短消息数量上限
清空环境,让用户发"测试"刷新 token, 然后以 5 秒间隔发 10 条 800 字符短消息:
结论:每窗口上限 7 条短消息。
5.2 实验 2 — 单条消息长度上限
让用户发"测试"刷新 token, 然后以 5 秒间隔发不同长度的单条消息:
结论:单 chunk 字符上限 4000(实测 4000 ERR,3500 OK)。
5.3 实验 3 — 长消息消耗更多配额
发 5 条 3500 字符长消息,5 秒间隔:
结论:一条 3500 字符长消息 ≈ 7 条 800 字符短消息配额。
5.4 实验 4 — 自然冷却 vs 用户消息刷新
耗尽配额后,分别测试自然冷却和用户消息触发:
结论:配额由用户入站消息刷新,自然冷却不起效。
5.5 完整配额模型
| |
|---|
| 3500 |
| 7 条 800 字符短消息 或 1 条 3500 字符长消息 |
| 用户入站消息 |
| HTTP 200 + ret -2 + "prepare failed" |
| |
■六、确认问题:双限制 + 单一刷新通道
6.1 真正的问题
把上面的规律综合起来,问题是双限制叠加:
•单 chunk 字符上限 4000(实测 4000 ERR,3500 是安全值)•每 token 7 次配额(HTTP 200 + -2)真实表现:
•8 章报告 × 1500 字符/章 → splitAndFilterMarkdown 切 16 chunks一个 token 7 次的限制,是这个问题的核心。
6.2 三个独立的挑战
•怎么在 7 条以内完成所有发送? —— 单 chunk 字符上限 4000•失败的部分怎么在下次窗口恢复时重发? —— 监听用户入站消息
■七、解决问题:错误完整捕获 + 协议合规
7.1 第一步:错误完整捕获
没捕获错误前,根本不知道 -2 是什么。 先扩展 ApiError 加上 rawText 和 httpUrl 字段, 让调试日志能打印完整的 HTTP 状态、URL、原始响应体:
export class ApiError extends Error { readonly status?: number; readonly payload?: unknown; readonly rawText?: string; // 新增 readonly httpUrl?: string; // 新增if (!response.ok || (data && typeof data === "object" && "ret" in data && data.ret !== 0)) { const message = data?.errmsg ?? `${endpoint} failed with HTTP ${status}`; throw new ApiError(message, { status, code: data?.ret, payload: data, rawText: text, httpUrl: url.toString(),[SENDTEXT-ERR] 调试日志加上http_status + url + raw_body 完整输出。
这一步是后续所有优化的诊断基础。
没有完整错误捕获,根本不知道是协议违规还是配额问题。
7.2 第二步:协议合规
- const CHANNEL_VERSION = '1.0.0';+ const CHANNEL_VERSION = '2.0.0';+ // X-WECHAT-UIN 每请求重新生成+ function generateWechatUin(): string {+ return crypto.randomBytes(4).readUInt32BE(0).toString('base64');实测:协议合规没改变错误模式, 但消除了一个混淆变量。
■九、具体的优化方案
9.1 优化 1:单 chunk 提到 3500 字符
export const DEFAULT_CHUNK_SIZE = 3500; // 原 800为什么是 3500 不是 4000? 因为 4000 实测 ERR,3500 是实测安全值。
效果:同长度报告的 sendmessage 数量从 16 → 5(节省 69%)。
9.2 优化 2:sendRepliesToWechat 配额感知
const MAX_CHUNKS_BEFORE_REFRESH = 6;const SLEEP_BETWEEN_BATCHES_MS = 5_000;for (let i = 0; i < allChunks.length; i++) { const batchEnd = Math.min( i + MAX_CHUNKS_BEFORE_REFRESH, for (let j = i; j < batchEnd; j++) { await client.sendText(targetUserId, allChunks[j]); if (batchEnd < allChunks.length) { await sleep(SLEEP_BETWEEN_BATCHES_MS);每批 6 条 —— 7 条配额里留 1 条给 reminder 和重试。
优化空间:
•短消息场景下,每批 6 条 + 5 秒间隔正好用满 7/7 配额•长消息场景下,1 条 3500 字符 ≈ 7 条短消息配额, 每批 6 条 → 实际只发 1 条就耗尽窗口9.3 优化 3:失败不抛 + pending 缓存
const remainingChunks = allChunks.slice(sentCount); if (remainingChunks.length > 0) { await this._savePendingReplies( const warnText = `⚠️ ${err.message}。后续 ${remainingChunks.length} 条未发,请发任意消息继续。`; await client.sendText(targetUserId, warnText);核心设计:失败时不抛出, 缓存剩余 chunks + 发一句提醒 + 等待用户消息触发 flush。
9.4 优化 4:sendtyping UX 提升
// client.ts sendText 加包装async sendText(userId: string, text: string): Promise { await this.startTyping(userId).catch(() => {}); // ...apiSendMessage 实际发送... await this.stopTyping(userId).catch(() => {}); await this.stopTyping(userId).catch(() => {});让 ClawBot 在 sendmessage 实际发生时显示「对方正在输入」。
9.5 优化 5:用户消息入站触发 flush
pi.on("agent_end", async (event, ctx) => { if (turn.wechatConversationActive && newReplies.length > 0) { await queue.sendRepliesToWechat(newReplies, turn.targetUser); log(`[AGENT-END-ERROR] ${formatError(err)}`);void queue._flushPendingReplies(message.userId).catch(err => log(...));配合 fork 已有的 context_token 自动刷新机制, 每次用户消息进来都刷新 token + 触发 flush。
9.6 优化 6:Buffer 合并短消息
前面 5 个优化解决了「超额部分怎么办」, 但没解决根因——为什么会有 16 个 chunks?
回看代码——一个 agent 回复会被 splitAndFilterMarkdown 按 3500 字符切。 但 13 章报告每章 1500 字符,短回复被切成 8 个 800 字符 chunks + 8 个 7 字符 chunk 标题, 总共 16 个 sendmessage。
真正的优化方向:不是切碎发送,而是合并发送。
混合触发 buffer:
accumulatedChars: number; timer: ReturnType | null;private replyBuffers: Map = new Map();触发条件(任一满足即 flush):
•size 阈值:累积 ≥3500 字符 → 立即 flush•时间窗口:8 秒无新 reply → 自动 flush•turn 结束:agent_end 事件 + 1.5s 延迟 flush•最大年龄:30 秒强制 flush(防 buffer 久散)sendRepliesToWechat 改为 buffer 模式:
async sendRepliesToWechat( if (!this.getClient()) return; const newChunks: string[] = []; for (const reply of replies) { const chunks = splitAndFilterMarkdown(reply); for (const chunk of chunks) newChunks.push(chunk); if (newChunks.length === 0) return; const cfg = getConfigCache(); const FLUSH_INTERVAL = cfg.bufferFlushIntervalMs ?? 8_000; const SIZE_THRESHOLD = cfg.bufferSizeThresholdChars ?? 3_500; const MAX_AGE = cfg.bufferMaxAgeMs ?? 30_000; let buf = this.replyBuffers.get(targetUserId); lastAppendAt: Date.now(), this.replyBuffers.set(targetUserId, buf); for (const c of newChunks) { buf.accumulatedChars += c.length; buf.lastAppendAt = Date.now(); if (buf.accumulatedChars >= SIZE_THRESHOLD) { await this._flushBuffer(targetUserId, "size"); if (Date.now() - buf.createdAt >= MAX_AGE) { await this._flushBuffer(targetUserId, "age"); if (buf.timer) clearTimeout(buf.timer); buf.timer = setTimeout(() => { void this._flushBuffer(targetUserId, "time");_flushBuffer 合并 + 重拆:
private async _flushBuffer( reason: "time" | "size" | "turn" | "age", const buf = this.replyBuffers.get(userId); this.replyBuffers.delete(userId); if (buf.chunks.length === 0) return; // 关键:合并为单 reply(用双换行分隔),splitAndFilterMarkdown 会重拆 const merged = buf.chunks.join("\n\n"); `[BUFFER-FLUSH] user=${userId} reason=${reason} inputChunks=${buf.chunks.length} chars=${buf.accumulatedChars}`, await this._sendMergedChunks(merged, userId);合并的妙处:
splitAndFilterMarkdown 不感知 buffer, 它只看到一段连续文本,按 3500 字符重切—— 重切的对象已经是合并后的完整内容, 自然不会再产生「切碎小回复」的碎片。
9.7 实施清单
改动文件总览(按 commit 时间顺序):
| | |
|---|
| 防御性字符过滤 + UTF-8 字节硬上限(解决问卷卡顿) | |
| 错误完整捕获(http_status + httpUrl + raw_body) | src/api.ts, src/client.ts |
| 协议合规(channel_version + X-WECHAT-UIN) | |
| 配额感知 + sendtyping + pending 缓存(每批 2 条) | src/queue.ts, src/client.ts, src/auth.ts, src/index.ts |
| | src/queue.ts, src/auth.ts, src/index.ts |
| | |
5 天的 commit,覆盖了从协议合规、错误诊断、缓存机制到配额削峰的全部改造。
■十、最终效果:卡主不发消息的频率大大改善
10.1 实际改善
修复之前:13 章报告只显示前 7 章,剩下的全部丢失。
修复之后:
•buffer 模式:短消息合并为 1 个 chunk,sendmessage 配额使用降到 1/7•失败缓存:超额的部分缓存到 pending-replies.json,等用户入站消息触发 flush•sendtyping:用户能看到「对方正在输入」,知道 agent 在工作10.2 实测数据
清空 ~/.pi/agent/wechat-assistant/pending-replies.json, 跑端到端测试:
3 回合连续对话,每回合 3 条短 reply,每条 100 字符:
=== test: 3 回合对话,每回合 3 条短 reply ===[BUFFER-FLUSH] user=... reason=turn inputChunks=3 chars=195round 1 flushed, buffer=0[BUFFER-FLUSH] user=... reason=turn inputChunks=3 chars=195round 2 flushed, buffer=0[BUFFER-FLUSH] user=... reason=turn inputChunks=3 chars=195round 3 flushed, buffer=0debug.log 实际记录:
[07:02:24] [SENDTEXT] len=433 短回复1+2+3+4+5 合并 → OK[07:02:36] [SENDTEXT] len=199 round1: r1#1+r1#2+r1#3 合并 → OK[07:02:41] [SENDTEXT] len=199 round2: r2#1+r2#2+r2#3 合并 → OK[07:02:46] [SENDTEXT] len=199 round3: r3#1+r3#2+r3#3 合并 → OK0 SENDTEXT-ERR。
10.3 配额节省率
| | | |
|---|
| | 1 sendmessage | |
| | 3 sendmessage | |
| 16 chunks → 8 sendmessage(失败) | | |
| | | |
平均配额节省:55%。
10.4 卡住时的实际体验
修复后遇到 sendmessage 配额耗尽时:
•ClawBot 显示「对方正在输入」(sendtyping 起作用)•可以在 ClawBot 发任意一句话询问最新进展, 比如「现在进展如何?」•这条入站消息会触发 context_token 刷新 + flush pending 缓存•agent 收到「最新进展」问题后,会继续发送缓存中的剩余内容这就是 sendtyping + pending 缓存的组合价值——
卡住时用户知道 agent 在工作, 并且能通过一条入站消息触发 agent 继续。
10.5 三条经验
经验 1:错误码的字面含义往往是错的
iLink 协议里 ret -2 字面意思是「参数错误」, 但实际触发它的可能是 rate limit、stale token、protocol mismatch、payload shape change 等多种原因。
永远靠实测触发模式来推断,不靠文档字面含义。
经验 2:协议合规和配额感知是两件不同的事
协议合规解决了「请求被服务端拒绝」的问题, 但没有改变配额。
配额是账号级独立维度, 单独需要 buffer 模式来削峰。
两者缺一不可。
经验 3:失败的回复不要抛,要缓存 + 触发重试
如果 sendmessage 失败时直接抛错,会导致:
缓存到 pending-replies.json + 用户入站消息触发 _flushPendingReplies 才能让失败可恢复、不重复消耗 agent 计算。
■十一、安装方式
git clone https://gitee.com/linbirg/pi-extentions.gitcd pi-extentions/src/wechat-assistant# 在 pi TUI 内输入 /wechat login,扫码登录 ClawBot启动后 agent 的所有回复都会通过 ClawBot 发送到你的微信。
■🔴 排查这类「明明能跑但跑不全」的问题, 靠的是真实的实测数据,不是文档字面意思。
源码仓库:https://gitee.com/linbirg/pi-extentions
🔴 排查这类问题的核心方法
靠实测触发模式推断,不靠文档字面意思。
源码仓库:https://gitee.com/linbirg/pi-extentions