乐于分享
好东西不私藏

pi agent 插件开发实录:官方文档没说的 7 次配额排查 —— pi-wechat-assistant改造的踩坑记录

pi agent 插件开发实录:官方文档没说的 7 次配额排查 —— pi-wechat-assistant改造的踩坑记录

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

几个细节很怪:

HTTP 状态码都是 200(请求到了服务端)
错误都是 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
拉取新消息(长轮询 35s)
/ilink/bot/sendmessage
发送消息
/ilink/bot/sendtyping
输入提示

错误码表:

ret: 0 = 成功
ret: -14 = session 过期,需要重新登录
ret: -2 = 参数错误(字面意思)
HTTP 4xx = 鉴权失败
HTTP 5xx = 服务端错误

2.2 腾讯云开发者文章

另一份 https://cloud.tencent.com/developer/article/2651968 描述了buildHeaders 必须包含:

AuthorizationType: 'ilink_bot_token'
Authorization: Bearer 
X-WECHAT-UIN(每请求重新生成)

它没解释 -2 的具体含义, 但展示了协议合规的正确实现。

2.3 CSDN 排查文章

第三份 https://blog.csdn.net/riyuexinzhu/article/details/161588596 提到:

「ret -2 可能是 stale context_token 或者真实的 rate limit, 需要靠触发模式区分」

但这份资料没说明触发模式具体长什么样。

2.4 三份资料的矛盾

官方协议
腾讯云
CSDN
-2 语义
参数错误
未提
stale token / rate limit
rate limit 返回
HTTP 4xx/5xx
未提
未提

没有一个文档明确说:「-2 + HTTP 200」= 配额耗尽。


三、排查:先看 fork 自己的代码

3.1 fork 协议合规检查

打开 src/wechat-assistant/src/api.ts

const CHANNEL_VERSION = '1.0.0';  // 官方要求 2.0.0
const 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 秒
payload 大小
全部 800 字符,完全相同
context_token
第 8 条 ERR 时的 token 是新的
HTTP 状态
200
错误体
{"ret":-2,"errmsg":"prepare failed"}
自然冷却
等 30 秒后再发,仍然 ERR
用户消息触发
让用户发"再试一次",刷新 token,重发立即成功 7 条

4.2 关键规律

每轮成功的 sendmessage 数量恒定为 7 条 + 每轮成功的窗口由用户入站消息触发刷新

这个规律与协议文档不符:

协议说 rate limit 应该返回 HTTP 4xx/5xx
实测返回 HTTP 200 + -2
协议说 -2 是「参数错误」
实测触发模式明显是「配额耗尽」

4.3 推断

-2 在我们这个 bot 账号上 = sendmessage 端点的账号级配额

不是协议字面意义上的「参数错误」。

但这个判断只是推断,需要进一步测试验证。


五、测试:把配额曲线画出来

5.1 实验 1 — 短消息数量上限

清空环境,让用户发"测试"刷新 token, 然后以 5 秒间隔发 10 条 800 字符短消息:

#1 OK
#2 OK
#3 OK
#4 OK
#5 OK
#6 OK
#7 OK
#8 ERR
#9 ERR
#10 ERR

结论每窗口上限 7 条短消息

5.2 实验 2 — 单条消息长度上限

让用户发"测试"刷新 token, 然后以 5 秒间隔发不同长度的单条消息:

长度
结果
1000 字符
OK
2000 字符
OK
3500 字符
OK
4000 字符
ERR

结论单 chunk 字符上限 4000(实测 4000 ERR,3500 OK)。

5.3 实验 3 — 长消息消耗更多配额

发 5 条 3500 字符长消息,5 秒间隔:

#1 ERR
#2 ERR
#3 ERR
#4 ERR
#5 ERR

结论一条 3500 字符长消息 ≈ 7 条 800 字符短消息配额

5.4 实验 4 — 自然冷却 vs 用户消息刷新

耗尽配额后,分别测试自然冷却和用户消息触发:

触发方式
恢复时间
自然等 30 秒
未恢复
,再发仍 ERR
自然等 5 分钟
未测(超时)
让用户发任意消息
立即恢复

结论配额由用户入站消息刷新,自然冷却不起效

5.5 完整配额模型

维度
数值
单 chunk 字符上限
3500
(4000 返回 -2)
Refresh window 配额
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
第 1-7 chunks 成功
第 8-16 chunks 全部 ERR
用户没新消息进来 → 配额不会刷新
报告永远显示不全

一个 token 7 次的限制,是这个问题的核心

6.2 三个独立的挑战

怎么在 7 条以内完成所有发送? —— 单 chunk 字符上限 4000
超额部分怎么办? —— 失败不抛,缓存等下次刷新
失败的部分怎么在下次窗口恢复时重发? —— 监听用户入站消息

七、解决问题:错误完整捕获 + 协议合规

7.1 第一步:错误完整捕获

没捕获错误前,根本不知道 -2 是什么。 先扩展 ApiError 加上 rawText 和 httpUrl 字段, 让调试日志能打印完整的 HTTP 状态、URL、原始响应体:

export class ApiError extends Error {
  readonly status?: number;
  readonly code?: number;
  readonly payload?: unknown;
  readonly rawText?: string;   // 新增
  readonly httpUrl?: string;    // 新增
}
// parseJsonResponse 里
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 字符

// src/message.ts
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,
    allChunks.length,
  );
  for (let j = i; j < batchEnd; j++) {
    await client.sendText(targetUserId, allChunks[j]);
    sentCount++;
  }
  if (batchEnd < allChunks.length) {
    await sleep(SLEEP_BETWEEN_BATCHES_MS);
  }
}

每批 6 条 —— 7 条配额里留 1 条给 reminder 和重试。

优化空间

短消息场景下,每批 6 条 + 5 秒间隔正好用满 7/7 配额
实测 7 条短消息可以在 5 秒窗口内连续发送
长消息场景下,1 条 3500 字符 ≈ 7 条短消息配额,   每批 6 条 → 实际只发 1 条就耗尽窗口

9.3 优化 3:失败不抛 + pending 缓存

catch (err) {
  const remainingChunks = allChunks.slice(sentCount);
  if (remainingChunks.length > 0) {
    await this._savePendingReplies(
      targetUserId,
      remainingChunks,
      sentCount,
    );
    const warnText = `⚠️ ${err.message}。后续 ${remainingChunks.length} 条未发,请发任意消息继续。`;
    try {
      await client.sendText(targetUserId, warnText);
    } catch {
      /* ignore */
    }
  }
}

核心设计:失败时不抛出, 缓存剩余 chunks + 发一句提醒 + 等待用户消息触发 flush。

9.4 优化 4:sendtyping UX 提升

// client.ts sendText 加包装
async sendText(userId: string, text: string): Promise {
  await this.startTyping(userId).catch(() => {});
  try {
    // ...apiSendMessage 实际发送...
  } catch (err) {
    await this.stopTyping(userId).catch(() => {});
    throw err;
  }
  await this.stopTyping(userId).catch(() => {});
}

让 ClawBot 在 sendmessage 实际发生时显示「对方正在输入」。

9.5 优化 5:用户消息入站触发 flush

// src/index.ts
pi.on("agent_end", async (event, ctx) => {
  // ...省略...
  if (turn.wechatConversationActive && newReplies.length > 0) {
    try {
      await queue.sendRepliesToWechat(newReplies, turn.targetUser);
    } catch (err) {
      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

// src/queue.ts
interface ReplyBuffer {
  userId: string;
  chunks: string[];
  accumulatedChars: number;
  timer: ReturnType | null;
  createdAt: number;
  lastAppendAt: number;
}
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(
  replies: string[],
  targetUserId: string,
): Promise {
  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);
  if (!buf) {
    buf = {
      userId: targetUserId,
      chunks: [],
      accumulatedChars: 0,
      timer: null,
      createdAt: Date.now(),
      lastAppendAt: Date.now(),
    };
    this.replyBuffers.set(targetUserId, buf);
  }
  for (const c of newChunks) {
    buf.chunks.push(c);
    buf.accumulatedChars += c.length;
  }
  buf.lastAppendAt = Date.now();
  // 1) 大小阈值 → 立即 flush
  if (buf.accumulatedChars >= SIZE_THRESHOLD) {
    await this._flushBuffer(targetUserId, "size");
    return;
  }
  // 2) 最大年龄 → 强制 flush
  if (Date.now() - buf.createdAt >= MAX_AGE) {
    await this._flushBuffer(targetUserId, "age");
    return;
  }
  // 3) 时间窗口 → 重置定时器
  if (buf.timer) clearTimeout(buf.timer);
  buf.timer = setTimeout(() => {
    void this._flushBuffer(targetUserId, "time");
  }, FLUSH_INTERVAL);
}

_flushBuffer 合并 + 重拆

private async _flushBuffer(
  userId: string,
  reason: "time" | "size" | "turn" | "age",
): Promise {
  const buf = this.replyBuffers.get(userId);
  if (!buf) return;
  this.replyBuffers.delete(userId);
  if (buf.timer) {
    clearTimeout(buf.timer);
    buf.timer = null;
  }
  if (buf.chunks.length === 0) return;
  // 关键:合并为单 reply(用双换行分隔),splitAndFilterMarkdown 会重拆
  const merged = buf.chunks.join("\n\n");
  console.log(
    `[BUFFER-FLUSH] user=${userId} reason=${reason} inputChunks=${buf.chunks.length} chars=${buf.accumulatedChars}`,
  );
  await this._sendMergedChunks(merged, userId);
}

合并的妙处

splitAndFilterMarkdown 不感知 buffer, 它只看到一段连续文本,按 3500 字符重切—— 重切的对象已经是合并后的完整内容, 自然不会再产生「切碎小回复」的碎片。

9.7 实施清单

改动文件总览(按 commit 时间顺序):

日期
改动
文件
8-24 早
防御性字符过滤 + UTF-8 字节硬上限(解决问卷卡顿)
src/message.ts
8-24 晚
错误完整捕获(http_status + httpUrl + raw_body)
src/api.ts, src/client.ts
8-25 早
协议合规(channel_version + X-WECHAT-UIN)
src/api.ts
8-25 午
配额感知 + sendtyping + pending 缓存(每批 2 条)
src/queue.ts, src/client.ts, src/auth.ts, src/index.ts
8-25 午
reply buffer 合并短消息
src/queue.ts, src/auth.ts, src/index.ts
8-25 午
每批上限 2 → 6(用满 7 条配额)
src/queue.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 ===
--- round 1 ---
[BUFFER-FLUSH] user=... reason=turn inputChunks=3 chars=195
round 1 flushed, buffer=0
--- round 2 ---
[BUFFER-FLUSH] user=... reason=turn inputChunks=3 chars=195
round 2 flushed, buffer=0
--- round 3 ---
[BUFFER-FLUSH] user=... reason=turn inputChunks=3 chars=195
round 3 flushed, buffer=0

debug.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 合并 → OK

0 SENDTEXT-ERR

10.3 配额节省率

场景
修复前
修复后
节省
5 条短回复(100 字符/条)
5 sendmessage(耗尽配额)
1 sendmessage
80%
9 条短回复(3 回合×3 条)
9 sendmessage(耗尽配额)
3 sendmessage
67%
8 章长报告(1500 字符/章)
16 chunks → 8 sendmessage(失败)
8 chunks → 4 sendmessage
50%
1 条 4000 字符长回复
5 chunks(超出 3500 失败)
5 chunks(不变)
0%(已是最优)

平均配额节省:55%

10.4 卡住时的实际体验

修复后遇到 sendmessage 配额耗尽时

ClawBot 显示「对方正在输入」(sendtyping 起作用)
用户看到 typing 但实际收不到消息
可以在 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 失败时直接抛错,会导致:

一条 chunk 失败 → 整批回复中断
用户看不到失败原因
重试需要重新触发 agent 回复,重复计算

缓存到 pending-replies.json + 用户入站消息触发 _flushPendingReplies 才能让失败可恢复、不重复消耗 agent 计算。


十一、安装方式

# 1. 克隆仓库
git clone https://gitee.com/linbirg/pi-extentions.git
cd pi-extentions/src/wechat-assistant
# 2. 安装依赖
npm install
# 3. 登录(首次使用)
# 在 pi TUI 内输入 /wechat login,扫码登录 ClawBot
# 4. 启动桥接
/wechat start

启动后 agent 的所有回复都会通过 ClawBot 发送到你的微信。


🔴 排查这类「明明能跑但跑不全」的问题, 靠的是真实的实测数据,不是文档字面意思。

源码仓库:https://gitee.com/linbirg/pi-extentions

🔴 排查这类问题的核心方法

靠实测触发模式推断,不靠文档字面意思。

源码仓库:https://gitee.com/linbirg/pi-extentions