Pi Agent × Feishu Bridge 开发复盘:从协议踩坑到单进程常驻架构
关键词:Pi Agent · Feishu · @larksuite/channel · RPC · single-process resident · secrets-getter
环境:Termux (Android aarch64) · Node.js v24.17.0 · @larksuite/channel v0.4.1 · pi-coding-agent v0.83.0
日期:2026-07-31
1. 目标与约束
目标很明确:让 Pi Agent(Mario Zechner 开源的 Agent Harness)通过飞书私聊接收消息并返回回复。
约束:
- Pi Agent 与 Hermes 隔离运行,独立进程、独立配置目录(
~/.pi/agent/vs~/.hermes/) - Bridge 不硬编码任何厂商 API,通过
pi --mode rpc或~/.pi/agent/models.json配置实现厂商无缝切换 - 飞书凭证优先走
secrets-getter,避免.env被系统脱敏
2. 方案选型:为什么放弃 wrapper 伪装
2.1 初始方案
lark-channel-bridge 支持 --agent claude,协议为 Claude stream-json。直觉思路是写一个 wrapper 叫 claude,内部转发给 pi --mode rpc:
1 |
|
2.2 验证结果
通过阅读 lark-channel-bridge/dist/cli.js 源码(~9000 行),确认其内部有独立的 agent 会话状态机:
preflight阶段调用一次claude --version(wrapper 被调用)- 收到飞书消息后,走内部
ClaudeAdapter/CodexAdapter - 消息链路不经过
exec(wrapper)
因此 wrapper 伪装只骗得过 preflight,骗不过消息链路。
2.3 决策
放弃 wrapper 方案,基于 @larksuite/channel SDK 自研最小 bridge,硬编码支持 Pi Agent。
3. 协议层踩坑实录
3.1 .env 被 Termux 脱敏为 ***
现象:Bridge 启动时 Token 获取成功(code: 0),但 Bot 信息查询失败,表现为鉴权失败。
根因:Termux 环境对 .env 文件有自动脱敏机制,LARK_APP_SECRET=xxx 被替换为 LARK_APP_SECRET=***,长度从 32 位变成 17 位。
验证:
1 | $ wc -c ~/pi-lark-bridge/.env |
修复:Bridge 不读 .env,优先调用 ~/.lark-channel/secrets-getter 读加密存储。
3.2 secrets-getter 协议格式错误
现象:secrets-getter 返回 {"protocolVersion":1,"values":{}},空值。
错误 payload:
1 | { "protocolVersion": 1, "requestId": "test", "items": [...] } |
正确 payload(参考 lark-channel-bridge 源码):
1 | { "protocolVersion": 1, "requestId": "test", "ids": ["app-cli_a951c9d094385cce"] } |
根因:字段名必须是 ids,不是 items。这是一个典型的协议文档缺失导致的字段名猜测错误。
验证:
1 | $ echo '{"protocolVersion":1,"requestId":"x","items":[{"id":"app-cli_xxx"}]}' | ~/.lark-channel/secrets-getter |
3.3 createLarkChannel 必须传 domain
现象:@larksuite/channel 初始化报 Invalid URL 错误。
根因:官方 README 未明确说明 domain 参数为必填。
修复:
1 | const channel = createLarkChannel({ |
4. 核心问题:Pi RPC 文本截断
4.1 现象
Bridge 能收到飞书消息,调用 pi --mode rpc 也返回成功,但没有 assistant 文本:
1 | {"id":"1","type":"response","command":"prompt","success":true} |
仅有一个空的 response 事件,无 text / assistant / delta 字段。
4.2 错误假设与验证
| 假设 | 验证方法 | 结果 |
|---|---|---|
--thinking off 能关闭 thinking block |
pi --mode rpc --thinking off |
仍无文本 |
| Step Plan API key 失效 | curl 直连 /messages |
Key 有效 |
| Pi anthropic-messages 适配问题 | 阅读 rpc-mode.js 源码 |
输出格式正常 |
| 每次 spawn 新进程丢失上下文 | 单进程常驻 + get_last_assistant_text |
成功 |
4.3 根因分析
阅读 pi-coding-agent/dist/modes/rpc/rpc-mode.js 源码(740 行),确认 RPC 模式支持 JSONL 流式输出,包含 text 事件:
1 | {"id":"1","type":"text","text":"Hello! How can I help you today?"} |
但每次 spawn('pi', ['--mode', 'rpc']) 新开一个进程:
- 新进程无历史对话上下文
- stdout pipe 被过早关闭/截断
- 只读到初始的
response事件,未等到text事件
4.4 修复:单进程常驻 + 请求队列
1 | // src/pi-adapter.js(158 行) |
关键设计:
- 单进程常驻,避免上下文丢失
requestId递增,pendingMap 实现请求-响应匹配text事件缓存到lastAssistantText,支持get_last_assistant_text轮询- 30 秒超时,防止僵尸请求
5. 调试方法论:最小测试用例隔离
在修 bridge 消息接收时,遇到一个诡异现象:test-minimal.js 能收到消息,但 src/index.js 收不到。
5.1 排除法步骤
- 写最小测试用例:只有
createLarkChannel + channel.on('message') + connect() - 验证 SDK 和飞书后端正常:
test-minimal.js稳定收到消息事件 - 对比 handler 注册逻辑:将
src/index.js的 message handler 改成与test-minimal.js完全一致的console.log(JSON.stringify(msg)) - 重启验证:收到消息,排除 SDK 问题
- 逐步恢复业务逻辑:定位到
runPi阻塞/异常导致后续消息无法处理
5.2 结论
最小测试用例隔离法将问题范围从“飞书后台 / SDK / 代码 / 网络”四个未知,缩小到了“业务逻辑内部”。
6. 最终架构
1 | ┌─────────────┐ websocket ┌──────────────────┐ pi --mode rpc ┌─────────────┐ |
数据流:
- 用户发送消息 → 飞书服务器
@larksuite/channelSDK 通过 websocket 长连接接收src/index.js的messagehandler 触发src/pi-adapter.js通过 stdin 写入 JSONL 到单进程pi --mode rpc- Pi 处理完成后输出
text事件 - Bridge 缓存文本,通过
channel.send()回复飞书
关键设计决策:
- Pi 模型服务由 Pi 自身管理:Bridge 不硬编码 Step Plan 或其他厂商 API
- 凭证优先走 secrets-getter:绕过 Termux
.env脱敏问题 - 单进程常驻:避免每次 RPC 新开进程丢失上下文
- requestId + pending Map:实现异步请求-响应匹配
6.1 架构图
7. Checklist:如果你也想在 Termux 上做类似桥接
- 飞书后台:PersonalAgent + 机器人 +
im.message.receive_v1+ 长连接已开启 -
.env不存敏感信息,Termux 会脱敏为*** -
secrets-getterpayload 用ids字段,不是items -
createLarkChannel必须传domain: 'https://open.feishu.cn' - Pi RPC 不要每次 spawn 新进程,用单进程常驻 + 请求队列
-
pi --mode rpc返回的是 JSONL 流,需要流式解析text事件 - 测试时用
test-minimal.js隔离 SDK 层,排除业务逻辑干扰
8. 可复用的工程经验
| 经验 | 说明 |
|---|---|
| 协议先行 | 在写业务代码前,先手动验证外部协议(secrets-getter 传什么字段、Pi RPC 输出什么格式) |
| 隔离环境问题 | .env 脱敏、文件权限、Node 多进程互斥,都是环境问题,不是代码问题 |
| 单进程优于 spawn-per-request | 对于有状态的交互式 CLI(如 Pi RPC),spawn 新进程会丢失上下文,单进程常驻 + 请求队列才是正解 |
| 最小测试用例 | 遇到“A 能 B 不能”时,先写一个只有 B 最小逻辑的脚本,把变量控制在 1 个 |
| 源码级验证 | 不要信 README,直接 grep / sed 查看第三方工具源码(如 lark-channel-bridge 的 secrets 子命令) |
| 不要盲改配置 | 先让用户/文档确认外部配置,再调试代码 |
9. 后续优化方向
- Termux 后台常驻:通过
termux-services或tmux/screen实现开机自启 - 流式响应:监听
text事件实时转发到飞书,不用等get_last_assistant_text轮询 - 多 provider 热切换:通过
models.json实现 StepFun / OpenAI / Anthropic 无缝切换 - 错误处理增强:Pi 超时、网络断开、飞书限流的指数退避重试
10. 附录:关键代码路径
| 文件 | 行数 | 职责 |
|---|---|---|
src/config.js |
76 | secrets-getter 协议(ids 格式),返回 32 位真实 secret |
src/pi-adapter.js |
158 | 单进程常驻 + 请求队列,解决 stdout 截断问题 |
src/index.js |
62 | createLarkChannel + message handler + channel.send |
test-minimal.js |
42 | 最小测试用例,验证 SDK 和飞书后端 |
~/.pi/agent/models.json |
— | Pi provider 配置(stepfun,baseUrl: https://api.stepfun.com/step_plan) |
写于 2026-07-31,Termux + Feishu + Pi Agent 折腾实录。