我们群里住着两个 bot。一个是我写的 Pi Telegram Agent,一个是群友 tcdw(碗)写的塑料碗。两个项目没有共享一行代码,功能、规模、写法都不一样,但凑到一起聊设计的时候,我们总在说同一句话:省一点,再省一点。
这篇文章是我的 bot 三个月生产账本的复盘,也是两种设计的对照。
TL;DR
- token 是唯一的货币。 成本是 token,上下文是 token,代码在 coding agent 眼里也是 token。三件事是同一笔账。
- 账单几乎全是“读”。 三个月 74,644 次模型调用,输入是输出的 374 倍;每次请求里将近九成是聊天历史,工具 schema 只占 1.1%。
- 少调用。 被点名就回,这不需要判断;其余消息要不要接,交给一个只看最近几行的小判断模型,每次约 1,650 token、$0.000033,不进入 bot 的上下文。
send成功即结束本轮,平均每轮 1.13 次调用。 - 命中缓存。 前缀只追加、永不改写,动态信息有自己固定的位置,golden hash 测试守住每一个字节。DeepSeek 上实测命中率 98.7%,同样的输入量,费用是不用缓存的 1/30。
- 上下文短而干净。 生产用 128K 窗口:API 按 token 线性计费,窗口再小只会换来更多压缩调用和冷缓存。不做记忆系统,原文全部保留,bot 不懂就自己去查。
- 省下来的花在体验上。 codemode 让 bot 先说“我去查一下”再并行干活,群友觉得它快;一个隔离好的 Linux 容器,让模型有了完全通用的能力,而我不用为每个能力写插件。
- 代码也要省。 把会话、压缩、恢复、执行、看图全部交给 Pi,
src/从 17,500 行砍到 8,100 行,功能反而更多。 - 和塑料碗。 它是“通用的产品,克制的 agent”,我是“专用的部署,通用的 agent”。我认为后者在群聊里更好,但也有好几处是碗做得更对。
下面一节一节展开。
账本:钱花在了哪
我的 bot 从 8 月 7 日上线,到 11 月 4 日换成新的部署方式之前,每一次模型调用都记了一行遥测:输入多少、命中缓存多少、输出多少、请求里各段各占多少。那时我的部署里跑着两个 bot,住在同一个群里,三个月的总账是这样的:
规模
- 群友消息
- 144,148
- 路由开始记录后的人类消息
- 模型调用
- 74,644
- 含 11,544 次压缩调用
- 公开发言
- 39,615
- send 次数
Token
- 输入
- 5.34B
- 其中缓存读取 3.50B
- 输出
- 14.3M
- 输入是输出的 374 倍
- 每轮调用
- 1.13
- 非压缩调用 ÷ 开启的轮次
输入 token 含命中缓存的部分。
输入是输出的 374 倍。群友说一句话,模型要把 system prompt、工具 schema 和整段聊天历史从头读一遍,才能决定回什么;而它回出去的往往只有一两句话。群聊 bot 的账单几乎全是“读”。
读的都是什么?四万多次请求记了分段大小,平均下来是这样的,旁边放着我之前改造 Memoh 时抓到的一次请求作对照:
| 部分 | 本项目 | Memoh 改造前 |
|---|---|---|
| 聊天历史 | 86.6% | 68.0% |
| system prompt | 10.2% | 10.7% |
| 压缩摘要 | 2.1% | 0% |
| 工具 schema | 1.1% | 21.3% |
本项目:40,593 次请求的平均值,平均每次 93,067 token。Memoh:改造前抓的单次请求,按字节计。
这张表决定了后面所有的优先级:真正的大头是上下文有多长,以及它能不能命中缓存。
成本可以写成:
N 是调用次数,T 是每次读的 token,h 是缓存命中率,r 是缓存价和原价的比(DeepSeek 是 1/50,Claude 是 1/10)。能动的只有三个地方:少调几次(N),读过的尽量命中缓存(h),每次少读一点(T)。接下来三节按这个顺序讲。
少调用:别为“什么都不做”付钱
最便宜的调用是不发生的调用。先看三个月里消息到调用的漏斗:
| 阶段 | 数量 |
|---|---|
| 群友消息 | 144,148 |
| bot 正忙,消息并入下一轮 | 50,532 |
| 开启一轮 | 55,776 |
| 模型调用 | 63,100 |
| 公开发言 | 39,615 |
开启的轮次 = 路由判定为 started 的消息数;模型调用不含压缩。
漏斗里最刺眼的是最后一行:十四万条群友消息,bot 公开说话不到四万次。群里绝大多数消息本来就和 bot 无关,而开了一轮以后,也有一万六千轮是模型读完选择了沉默。所以这一节要回答两个问题:哪些消息值得叫醒主模型;叫醒以后,怎么让这一轮尽快结束。
亮点:要不要接话,交给一个只看几行的小模型
“这条消息我要不要接”听起来是一个问题,拆开看其实是两个。
第一个问题有确定的答案。 被 @、被回复、被叫名字,就必须回。这里不需要任何判断,一行规则就够了,也不该让任何模型来否决它。
真正难的是剩下的消息。 它们需要的是判断,那就先问:做这个判断,到底需要知道什么?
想一想一个懂分寸的群友是怎么决定插不插话的:
- 有人向全群提了个问题,还没人回答;
- 有人在接着他刚才的话追问;
- 两个人在约饭,问的是特定的人;
- 别的 bot 已经把问题答完了;
- 只有几个 bot 在互相接话。
这些判断全都只取决于最近几行消息。它不需要 bot 的人设,不需要三个月的历史,也不需要工具。
这样一想,两种常见做法的问题就很清楚了。
交给主模型。 它当然能判断,但它每一次被叫醒,都带着九万多 token 的上下文。问它一句“要不要说话”,答案多半是“不用”,于是每一次“不说”都付了全价。而上面的漏斗告诉我们,“不说”才是常态,这笔钱会被乘上十几万次。
交给规则。 概率、关键词、时间段,都不花钱,但规则读不懂群里在聊什么:严肃的话题照样插一句,没人回答的问题照样装没看见。
我们要的东西,介于两者之间:能读懂,但只读几行;要一个判断,不要一段话。 顺着这几个条件,它的形状就出来了:一个分类模型,输入是最近的消息切片,输出是一个概率。
我选的是 OpenRouter 上的 pplx-decider-v1.2。它不生成文字,读一段 state,回答预先定义好的问题,返回每个问题的概率。我只问它一件事:bot 现在要不要说话。它便宜在三个地方:
- 只看切片。 它读的是这次新到的几行消息,加上前面六行作上下文,渲染成和主模型一样的文本行;人设、历史、工具一概不给。
- 不进上下文。 判断是一个独立的请求,结果只是一个数。主模型的前缀一个字节都没动,缓存照常命中。
- 小。 每次判断约 1,650 token(其中大部分是固定的说明和例子),$0.000033,不到一秒。
| 方式 | 输入 token | 每千次费用 |
|---|---|---|
| 主模型读完整上下文 | 93,067 | 取决于主模型 |
| 判断模型只读切片 | 1,650 | $0.033 |
主模型一列取账本里的平均请求;判断模型一列是带上全部例子后的实测。
按三个月 14.4 万条群友消息算,就算每一条都判断一次,总共也只要约 $4.7。
小模型的代价是不够聪明,那就补上它缺的东西:例子。 我让一个 subagent 去读真实的群聊记录:
- 先标注 150 个真实切片,留出 50 个做测试;
- 再凭空写出 14 条通用的例子,覆盖上面那几类情况。
结果是:
| 配置 | AUC | 准确率 | 精确率 | 召回 |
|---|---|---|---|---|
| 只有说明,阈值 0.51 | 0.88 | 0.84 | 1.00 | 0.53 |
| 说明加 14 条例子,阈值 0.45 | 0.92 | 0.82 | 0.75 | 0.71 |
50 个留出切片,“该接话”为正类;测试集小,每个数字约有 ±0.05–0.1 的误差。
不加例子时,模型太保守,一半该接的话都漏掉了。加了例子以后,召回从 0.53 升到 0.71。
这些例子用 {name} 代替 bot 的名字,和提示词一起写在 src/agent/decider.ts 里,随代码发布。它们是“判断要不要接话”这一块自己的一部分,部署的人不需要另外去写。
剩下几条规则,也都是从同一个问题推出来的:这个判断还需要知道什么,又不该知道什么?
- 别的 bot 的发言也交给它判断,而且永远不算“点名”。 群里住着十几个 bot,谁 @ 谁都可能形成循环。所以“只有 bot 在互相接话,就不要加入”专门写成了一条例子。
- 忙的时候不判断。 bot 正在回话或在冷却时,新消息先排队;这一轮结束、冷却过去,再对攒下的消息判断一次。判断期间又来了消息,就再补一次。
- 判断失败就当作要接。 这和“没配置判断模型就每条都接”是同一个答案:宁可话多,也不装死。
- 调用走 Pi。 Pi 1.5 原生支持这类分类模型,它的 OpenRouter 模型目录里就登记着这个模型,认证也存在 Pi 里。我一行 HTTP 都没写。
亮点:发完就下班,结果未知就不重发
叫醒模型之后,第二个问题是“它说的话怎么发出去”。
最省事的做法是把模型的文本输出直接发到群里。但这样模型就没法“想一想再决定不说”,思考过程、自言自语、对工具结果的复述都可能漏到群里。所以两个 bot 都规定:普通 assistant 文本永远不会发到群里,只有调用 send 工具才会公开发言。 漏斗里开启的轮次比公开发言多了一万六千次,大致就是模型读完之后选择沉默的轮数。
有了 send,下一个浪费就出现了:通用 agent 循环里,工具调用完要把结果交还给模型,模型再读一遍全部上下文,说“好的,已经发了”。在群聊里这一次调用纯属浪费。我的 send 单独调用时用 Pi 原生的 terminate 直接结束本轮,不再回到模型;被点名却忘了 send 的轮次,最多补一次提示。这就是平均 1.13 次调用的来源。
最微妙的是发送失败。网络超时的时候,你不知道 Telegram 到底收没收到这条消息。常见做法是重试,但 Telegram 的发送不可回滚:一旦服务端已经创建了消息,重试就会让群里出现两条一模一样的话。我的规则是:只有确定性的 4xx 错误、而且还没有任何部分提交时,才把错误交还给模型重试;其余一律当作“结果未知”,不重发。 宁可偶尔漏一句,也不在群里复读。崩溃恢复也一样:Pi 恢复中断的任务时,结果未知的发送保持 no_retry。
命中缓存:前缀一个字节都不能动
各家的 prompt caching 都是前缀匹配:请求的前缀逐字节一致才能命中,中间任何一个字节变了,从那里往后全部作废。缓存价是原价的 1/10 到 1/50,所以命中率是整本账里杠杆最长的一项。拿 DeepSeek V4 Flash 的官方价(命中 $0.0028、未命中 $0.14 每百万 token)去套我三个月的输入量:
| 缓存命中率 | 输入费用 |
|---|---|
| 0%(每次全价重读) | $748 |
| 85% | $125 |
| 98.7% | $25 |
只算输入;输出 1,430 万 token 另计。这是假设性换算,不是我的实际账单。
从 85% 到 98.7%,看起来只差十几个百分点,费用差了五倍。98.7% 是我的 bot 在 DeepSeek 直连上的实测值:它的自动前缀缓存只要前缀不变就命中,最能说明请求结构本身做得怎么样。
所以我在 AGENTS.md 里写的第一条硬约束是:
永不改写已存在的 provider prefix;动态内容只以新 suffix 追加。每个请求必须是下一请求的严格前缀。
亮点:动态信息该放在哪
“严格前缀”说起来简单,真正考验它的是那些每一批都不一样的信息。sticker 就是最典型的一个。
bot 有一个固定的 sticker 目录,但群友也会发目录以外的 sticker,bot 收到以后其实也能发同一张。所以每一批消息里,如果出现了这样的 sticker,我想告诉模型:“这几张你也可以用。”这是一段动态信息。问题是,它该放在请求的哪里?
一条一条看条件,答案会自己收窄。
第一个条件:不能改写已经发过的前缀。 这一条排除了 system prompt。它在请求的最前面,只要动一个字节,后面九万 token 的缓存全部作废。
同样被排除的还有一个看起来最省的做法:只在最后一条消息后面挂一份“最新的候选列表”,下一批来了就换掉。它每次都只带一份列表,可上一次请求的结尾,在下一次请求里消失了,前缀就断在那里。我在线上量到过这件事的代价:
- 同一轮里的后续调用,平均只漏缓存约 780 token;
- 每个新 turn 却要漏掉约 13,600 token,哪怕两条消息只隔三十秒,真正新增的内容只有约 1,000 token。
为了省几十个 token 的列表,每轮多付一万多。
所以动态信息只能追加,而且追加之后永远留在原位。
第二个条件:不能被当成任何人说的话。 既然只能追加,最自然的做法是把列表拼进那批消息的正文里。可模型读到的就会是“最后一个群友贴了一份 sticker 列表”。我遇到过这种情况:bot 反复说群友“泄露了它的 sticker 列表”。
所以它必须是一条独立的消息,有自己的标签〔系统附注〕,并且 system prompt 要明确说明:这类消息不是任何群友发的,群里也没人看得到。
第三个条件:不能进入记忆。 上下文满了以后会被压缩成摘要。如果附注写进了会话记录,压缩器就会把它当成聊天内容来概括。前面那件事更糟的一半就出在这里:摘要把“群友泄露了列表”这句推测写成了事实,之后每一次压缩都继承它,最后只能开新会话。
所以附注只能出现在发给模型的请求里,不能写进持久化的会话记录。
三个条件叠在一起,答案就只剩一个形状:
- 附注作为一条独立的〔系统附注〕消息,紧跟在它那一批消息后面;
- 写下以后永远留在那个位置,之后每一次请求都原样带着它;
- 它存在那批输入 entry 的元数据里,每次请求前由 Pi 原生的
beforeRequest钩子投影进去; - 压缩器读的是持久化的字节,根本看不到它。
/** A batch's note is rendered once and stays at that position for the life of the context. */ts一次请求最后长这样(示意):
system …群聊协议、工具规则、人设…
# Sticker 目录
s3: 😺 得意的赞同,中等强度
s4: 😭 委屈到哭,夸张
…(共 120 行)
user [21:03:11] #18452 群友 A (@a): 这也太离谱了
[21:03:15] #18453 群友 B (@b): [sticker 😂]
user 〔系统附注〕这批消息里出现了你也能发送的 sticker:
s2091: 😂
assistant send({ message: "…", sticker: "s4" })
user [media_update #18453] [sticker: 笑到捶地,极度夸张]
[21:04:02] #18460 群友 A (@a): …每一次请求,都是上一次请求原样加上新的内容:模型这一轮的输出、新的一批消息、这一批的附注(如果有)。旧消息不会重复出现,旧附注也不会被拿掉或改写。
同样的三个条件,也决定了其他动态信息的位置:
- 固定的 sticker 目录是稳定的,所以可以放进 system prompt 的最后。它渲染一次以后就和会话一起冻结,只在会话本来就要重开的时候才刷新,免得后来补上的描述悄悄改掉前缀。
- 媒体描述是晚到的:一张图片刚进来时,消息里只有一个
[图片]占位符。描述生成好以后,不回头改那条消息,而是在之后的输入里追加一行[media_update #id] [图片: …]。
发送那一侧也一样:模型只写短 ID,send 在发出任何东西之前,先把它解析成这个 bot 自己的 Telegram file_id。解析不了就直接把错误交还给模型,绝不会发到一半才失败。
把这一节压成一句话就是:动态信息要有自己固定的位置,而且只出现在请求里,不进入会话记录。
塑料碗把同一条规则做成了 system prompt 的结构:
- 稳定段是按固定顺序拼接的协议、索引和人格,对一个 Conversation 逐字节不变;
- 注入段装着所有随时间变化的东西:当前时间、短期记忆的增量、睡眠状态、任务回执。它作为每批一条新的 user 消息追加。
sticker 目录在它那里算不可信数据,随批注入。
亮点:让测试守住字节
光靠自觉守不住这条线。一次重构、一次“顺手改个措辞”,前缀就断了,而且不会报任何错,只会在账单上体现出来。
所以我的 test/cache.test.ts 对所有会进入前缀的东西做 golden hash:system prompt 模板、每种工具 schema、codemode 的声明段、消息序列化的输出。任何一个字节变了,测试就红。要让它重新变绿只有两条路:改回去,或者 bump CACHE_SCHEMA_VERSION 并更新 golden,承认这是一次有意的协议变更。这个版本号现在是 52。
这个测试守的不是功能,是钱。塑料碗用的是 system_prompt_hash:哈希一变就丢掉旧 Context 重建,绝不让新旧前缀混在一起。
上下文:短而干净,需要时再取
账本的第一张表说了,九成的输入是聊天历史。所以剩下的问题是 T:上下文该留多长,旧的东西怎么忘,忘了的东西怎么找回来。
这周刚好读到两篇论文,一篇讲上下文长度,一篇讲记忆,正好对应这两个问题。
窗口该开多大
NVIDIA 的 You don't need long context 先算了一笔服务端的账:attention 模型生成每个 token 的代价随上下文深度线性增长。他们在 vLLM 上开着 prefix caching 实测,把服务窗口从 16K 开到 256K,每个生成 token 的成本:
来源:Belcak et al., You don't need long context(arXiv:2611.05306),vLLM + H100,开启 prefix caching。
然后他们测了另一件事:如果频繁压缩,把上下文一直压在 16K 到 48K,前沿模型在长程任务和长输入任务上几乎不掉点。最激进的一组实验里,agent 每个上下文只做一个动作:读任务和当前状态,做一步,更新状态,换一个全新的上下文。Opus 4.5 在 SWE-bench Verified 上还是 70.2%,基线是 70.0%。
论文的结论很有说服力,但它算的是推理服务商的账。对我这种调 API 的人来说,账单是线性的:每百万 token 一个价,第 10 万个 token 和第 1 个 token 一样贵。那 3.6 到 7 倍的成本差,由服务商自己消化了,不会出现在我的账单上。
线性计费下,窗口大小的取舍就变成了另一个问题:每次读得少,和压缩得少,哪个更划算? 窗口越小,每次请求读的 token 越少;但压缩越频繁,每次压缩都是一次额外的调用,要把整个窗口读一遍再写出摘要,压缩之后前缀从头开始,缓存是冷的;而且摘要越多,丢失的原文越多(下一节会讲到,这正是第二篇论文说的问题)。
我的 bot 在旧部署里把两头都走过一遍,账本记下了两种代价:
平均输入不含压缩调用本身。9 月用的是自己写的压缩器;9 月底把窗口从 131K 调到 256K。
9 月是自己写的压缩器最糟的时候:9 月 21 日那一周,45% 的模型调用是压缩调用。原因是图片:压缩切点按“字符数 ÷ 4”估算 token,图片只存在于 details 里,被算成 0,于是每轮都判断“需要压缩”,压完却几乎没变小,下一轮接着压。9 月底把窗口开到 256K,压缩几乎消失了,代价是每次请求平均读 13 万 token。
现在的部署落在中间:压缩交给 Pi 原生的结构化摘要,窗口 128K,读到 11 万 token 时压缩,保留最近约 2 万 token 原文。代码里的默认值是 64K,适合便宜、短上下文的模型;但对我的群来说,128K 是“压缩足够少、每次读得又不太多”的平衡点。
重的活也不放在聊天上下文里:查资料、写代码、跑研究交给 task,在 Pi 的独立子会话里做完,只把结果带回群聊。群聊的上下文因此一直是“群友在聊什么 + bot 刚做了什么”,而不是一份越滚越长的工作日志。这和论文里“每个上下文只做一件事”是同一个方向。
原话比摘要可靠
第二篇是 Is Complex Agentic Memory Always Needed?。他们拿一个最朴素的基线 Raw-RAG 去比十几个 agentic memory 系统:不抽取事实,不做摘要,不建图,不改写记忆,就是把原始对话切块、检索、重排,交给读者模型。结果它在七组设置里拿了六个第一。
系统之间的差距主要不在“拿到证据之后答不答得对”,而在“能不能拿到足够的证据”:
| 改动 | 结果 |
|---|---|
| 原文换成抽取的原子事实 | 测试时学习 47.68% → 15.37%,总分 −19.62 |
| 原文换成摘要 | 总分 −5.81 |
| 加图扩展 | 总分 −5.78 |
| 完整访问加工后的记忆库 | 仍低于直接给原文(57.5% 对 61.6%) |
| Mem0 去掉 UPDATE / DELETE | 准确率 67.36% → 69.42% |
来源:Bao et al., Is Complex Agentic Memory Always Needed?(arXiv:2610.30755)。
成本上,在 MemoryAgentBench 上维护这些记忆要花两千万到一亿多 LLM token、三到九个小时;Raw-RAG 只要十二分钟。
我的 bot 没有记忆系统。群里每条消息原样存进 SQLite,默认保留一年,建了 trigram 索引;模型需要回忆时,用 tg_messages 按关键词、发言人、昵称(包括改名前后)、时间、回复关系去查,拿回来的是原话。我们不完全是论文里的 Raw-RAG,检索是关键词加结构化过滤,没有 embedding,决定查什么的是模型自己;但精神一样:保留原文,把理解的工作留给读的时候。
前面 sticker 那次事故就是反例:摘要把一句推测写成事实,之后每次压缩都继承它。改写过的记忆一旦出错,会自己复制下去;原文不会。这也是我不想把窗口压到 64K 的另一个原因:窗口里多留一些原话,就少一些要靠摘要转述的东西。
亮点:不懂就查
我花了很多篇幅讲压缩和摘要,但它们在实际聊天里只是锦上添花。真正让群友觉得“这个 bot 懂”的,是另一件事:它发现自己不懂,会自己去查。
群聊里到处是梗:新番的台词、某个圈子的黑话、上周群里发生的事。没有哪个模型的训练数据能全部覆盖,也没有哪个摘要能提前记下所有将来要用到的东西。我的 bot 遇到看不懂的梗,不会硬接也不会装懂,而是先 search 查出处,再用自己的话接上去;遇到“上次那个谁说的那件事”,就用 tg_messages 翻群里的原话。新部署上线后的第一天里,它调用了 19 次 tg_messages、17 次 search。
这是两篇论文合在一起的结论:上下文保持短而干净,需要的知识在读的时候按需取回,而且取回的是原文。与其把一个越来越长的上下文、或者一份越来越厚的记忆当成 bot 的“知识”,不如给它一个干净的工作台,加上随时能去查的能力。前者每一轮都在花钱,后者只在真正需要时才花。
所以我的 system prompt 里关于搜索只有一句话:“需要外部信息时用 search,必要时读结果正文;转述注明来源,查不到就如实说。”查不查、查什么,交给模型自己判断。
工具和格式也是上下文
工具 schema 在请求里只占 1.1%,这是刻意的结果。我用固定工具目录:首次请求完整声明全部工具,codemode 里只列可调用的名字和返回类型,共享同一份参数 schema。迁到 Pi 之前,system prompt 里还装着一份近 300 行的工具示例文档;迁过去以后,参数由 Pi 原生 schema 解释,prompt 只补群聊语义,这份文档就删了。
消息格式也一样。两边都没有把 Telegram 的 JSON 原样塞给模型,而是渲染成紧凑的文本行:
[HH:mm:ss] #<消息id> 名字 (@username): 内容日期只在变化时插一行分隔,媒体是 [图片]、[sticker 😄] 这样的占位符。图片和视频的描述由聊天模型按媒体只生成一次,之后一直复用;真要核对细节,bot 用 tg_download 把文件取到工作区,再用 Pi 原生的 read 看图。原来那个专门看图的 view_image 工具,就这样被删掉了。
体验:快和能干,也是省出来的
到这里很容易得出一个印象:这个 bot 抠门。其实正相反。省下来的调用、缓存和上下文,最后都花在了两件群友能直接感觉到的事情上:快,和能干。
亮点:codemode,边说边做
群友夸得最多的一句是“你家 bot 好快”。它的模型并不比别人快,快的是 codemode。
先看一个普通 agent 循环怎么处理“帮我查查这个”:
- 模型读完上下文,调用
search查第一个关键词; - 结果回来,模型再读一遍全部上下文,决定再查第二个;
- 结果回来,再读一遍,决定读其中一个网页;
- 再读一遍,终于
send。
四次模型调用,每次都要等上一次的工具结果回来,每次都把整个上下文重读一遍;而整个过程里群友什么也看不到,只能盯着“正在输入”。工具结果原样进上下文,几页搜索结果、一整个网页,全都成为之后每一轮都要重读的历史。
codemode 换了一个思路:模型不再一个工具一个工具地调用,而是写一段脚本,在 Pi 的沙箱里一次跑完:
await tools.send({ text: "我去查一下这个梗的出处" });
const results = await Promise.allSettled([
tools.search({ query: "关键词 A 出处" }),
tools.search({ query: "关键词 B 梗" }),
tools.tg_messages({ text_contains: "关键词 A", limit: 10 }),
]);
const [a, b, history] = results.map((r) =>
r.status === "fulfilled" ? r.value : null,
);
text({
a: a?.slice(0, 800),
b: b?.slice(0, 800),
history: history?.rows.map(({ id, sender, text }) => ({ id, sender, text })),
});js这一段脚本同时做到了三件事:
- 先开口。 脚本第一行就是
send,群友立刻看到“我去查一下”,等待从“bot 是不是挂了”变成“它在干活”。codemode 里的send不会结束本轮,脚本继续往下跑;最后的正式回复仍然是一次单独的send。 - 并行。 几个查询同时发出去,用
Promise.allSettled等齐,挂掉一个也不影响其他。四次串行往返变成一次。 - 过滤。 只把需要的那几百字带回上下文,而不是几页原始搜索结果。省下的不只是这一轮,而是之后每一轮都要重读的历史。
为什么不用 provider 原生的并行工具调用?它能解决“并行”,但解决不了另外两件:同一批并行调用里没法先发一句话、等结果回来再决定下一步,结果也全部原样进入上下文。流式输出文本也不行,因为我的 bot 只有 send 能公开发言。codemode 是这三件事唯一能同时成立的形状。
codemode 可调用的工具是固定名单,system prompt 里只列名字和返回类型,参数 schema 和直接调用共享同一份,所以它几乎不增加前缀。它上线后的大约十天里有 303 次调用,104 次里有搜索,57 次里有 send,25 次用了 Promise.all。
亮点:一个 Linux 容器,胜过一百个插件
“能干”来自另一个设计:我的 bot 有一台真正的 Linux。
通过 Pi 原生的 read、write、edit、bash,它在一个 rootless Docker 容器里有自己的 /work,里面有 Python 和 uv、Node、git、ripgrep、ffmpeg、ImageMagick、poppler、CJK 字体,还有 Chromium 和浏览器自动化工具。于是:
- 转视频、剪音频、压图片,是
ffmpeg和convert的一行命令; - 算数据、画图表,是
uv run --with matplotlib python; - 想知道自己是怎么实现的,可以把自己的仓库 clone 到
/work/src,用rg读源码,再解释给群友听; - 做完的东西用
send的files直接发回群里。
这些能力我一行都没有为它们写过。模型本来就会用 Linux,我要做的只是给它一台 Linux。新部署上线第一天,bash 就被调用了 43 次,比 search 还多。
另一种常见做法是一个能力一个插件:网页读取一个,提醒一个,辞典一个,生图一个。每个插件都要写实现、写 schema、写说明、写测试,模型能做的事情永远等于作者已经写好的插件之和;群友想把一段视频转成 GIF,只要作者没写过,它就做不到。一个通用的执行环境,替代了无穷多个专用插件,这比任何一个插件都省代码,也省 schema。
这样做的前提是把隔离做对:
| 边界 | 做法 |
|---|---|
| 宿主 | 独立系统用户 tgbox 的 rootless Docker,和 bot 进程不是同一个用户 |
| 容器 | 只读根目录、--cap-drop ALL、no-new-privileges、uid 1000 运行 |
| 资源 | 内存、CPU、进程数上限,/tmp 是 256 MB tmpfs |
| 凭据 | 容器里没有模型认证,也没有任何宿主凭据;模型在宿主上调用,容器里只跑无凭据的 pi-env |
| 数据 | 只挂载 /work,每周一 05:00 自动清空;网络可按配置关掉 |
| 发文件 | 检查真实路径,禁止符号链接逃逸,限制大小 |
代码:仓库也是上下文
前面省的都是 bot 的 token。但写这个 bot 的也是 agent:我的项目几乎每一次改动都是和 coding agent 一起做的。对 coding agent 来说,仓库就是它的上下文窗口。每一行代码、每一层抽象、每一个兼容分支,都是下一次会话要读、要理解、要小心别碰坏的东西。
每个日期取当天某个提交的 src/**/*.ts 总行数。
最多的时候大约 17,500 行,11 月初的四天里砍到了约 8,500 行。同一段时间里,它多了一键安装器、原生图片输入、可恢复的后台脚本、跑在 Pi 沙箱里的 codemode。代码少了一半,功能变多了。
写这篇文章的同一天,我又按同一个原则砍了一轮:看图交给 Pi 的 read,视觉描述交给聊天模型,单实例交给 systemd。删掉的比加上的多,src/ 现在 8,089 行;新加的判断模型和上下文构成,都是群聊这一层自己的事。
亮点:凡是不属于群聊的,都不该由我来写
做到这一点只靠一个决定:凡是不属于“Telegram 群聊”的东西,都不该由我来写。
一个能长期在线的 agent 需要很多东西:会话持久化、消息队列、重试、上下文压缩、工具执行、子会话、后台任务和崩溃后恢复、几十家 provider 的模型目录和认证。这些东西和群聊无关,但每一样都很难写对。我的决策记录里有一张事故表:
| 时间 | 现象 | 根因 |
|---|---|---|
| 09-16 | 几乎每轮都压缩却压不小 | 图片在压缩切点里被算成 0 token |
| 09-16 | 429 之后被 @ 的消息被标成已回复 | 重试耗尽后 turn 正常结束 |
| 10-04 | 约 20 分钟不回复 | 图片超出预算,预检压缩连续失败 |
| 10-06 | bot 说群友泄露了它的 sticker 列表 | 候选列表拼在消息里,摘要把推测写成事实 |
四次事故里有三次出在我自己写的会话和压缩代码里,没有一个和 Telegram 本身有关。
Pi 1.5 的 Durable 把这些都做成了原生能力。迁移那一个提交删了 7,877 行,加了 3,082 行,删掉的是:自己维护的上下文状态和指纹;自己写的压缩扩展、中文摘要模板和 token 打包器(compaction.ts 372 行,token-packer.ts 246 行);回复义务表、session 裁剪、缓存观察器、运行时遥测;自己管理的后台进程和容器里的 task bridge;每轮都塞进 system prompt 的工具示例文档。
剩下的 8,100 行只做 Pi 不知道的事:Telegram 的轮询和消息归档、谁该回应的路由、消息序列化的 grammar、公开发送的规则、sticker 和媒体、管理员命令、沙箱的部署边界。我自己留的 provider 包装只剩两件 Pi 1.5 还没覆盖的事:给不配合取消的响应流加一个完整截止时间,以及把代理返回的 HTML 413 归一成 Pi 认得的 request_too_large。
这也是代码能越写越少的原因:新功能大多是组合,而不是实现。
- 上一节的 Linux 工作区,是把 Pi 的
read/write/edit/bash指向一个容器; - 后台研究是 Pi 的子会话加一份
AGENTS.md写的工作流; job是 Pi 可恢复的脚本工具任务;- codemode 是 Pi 的沙箱;
- 判断模型是 Pi 的 classify,看图是 Pi 的
read。
我写的是“它们在群里怎么表现”,不是“它们怎么运行”。
迁移之后,这些原则被写进了 AGENTS.md:
- Pi 原生优先。 动手前先查依赖包导出了什么,Pi 能做的事不自造轮子。
- 一套设计,不留兼容层。 Breaking change 随时可以做,只要求迁移干净、一步到位。v1.0.0 不提供旧会话导入;只有一个生产部署,旧库直接报错,比维护迁移阶梯更简单。
- 删代码优先于加抽象。 防御代码只防真实可能的分支。
- 测试只留守卫。
test/里只有 7 个文件:cache golden、零外网、沙箱和 codemode 边界、搜索、管理员权限。不为每个 bug 机械地补回归测试,也不断言 prompt 字符串。 - 常载文档要短。
AGENTS.md是 agent 每次会话都会自动读进去的文件,现在 75 行,只放非显然、会复发、可执行的约束。
和塑料碗:两种通用
聊到这里,碗问过我一个问题:你天天说想做通用的 agent,为什么只支持一个群?先看两个项目实际长什么样:
| Pi Telegram Agent | 塑料碗 | |
|---|---|---|
| 给谁用 | 我们自己的一个群 | 任何想自己部署的人,BYOK |
| 聊天范围 | 一个 supergroup | 私聊、群组、Forum Topic,按白名单 |
| 角色 | 每个部署一个带人设的 bot | 每个部署一个 bot,按 Chat 换人格和模型 |
| 要不要说话 | 点名必回;其余(含别的 bot)由只看切片的判断模型决定 | Bucket 聚合后由主模型判断,可加时段和概率闸门 |
| 上下文遗忘 | Pi 原生结构化摘要 | 只删不摘要的 checkpoint 滑动窗口 |
| 记忆 | 没有;原文归档 + tg_messages 查询 |
Memo Pad + 群聊事件记忆 + 人工审核的 agents.md |
| 工具暴露 | 固定目录,一次声明全部 | 原语直接暴露,内部能力经 execute 按需发现 |
| 执行能力 | Linux 容器里的 read/write/edit/bash、codemode、后台 task 和 job |
有意不开放 Shell 和任意代码执行 |
| 其他 | sticker、reaction、看图看视频、发文件 | sticker、看图、生图、提醒、辞典、受限 MCP |
| 运维 | 一键安装、systemd、群内 /model /status |
Docker、管理面板、虚拟群聊、备份 |
| 代码 | src/ 约 8,100 行,通用部分交给 Pi |
src/ 约 6 万行,另有前端和文档站 |
把这张表竖着看,两个项目的“通用”正好放在相反的两头。
塑料碗是通用的产品,克制的 agent。 它要被陌生人部署到陌生的群里,所以部署形态、聊天类型、配置、审计、管理面板都要做全;也正因为部署者千差万别,模型能做的事被收得很紧。
我的 bot 是专用的部署,通用的 agent。 它只服务一个群,所以部署和配置可以极简;省下来的复杂度全部给了 agent 本身。因为我只服务一个自己信任的群,才能把一整台 Linux 交给模型。
两种都成立。但在几个关键的设计点上,我认为自己这一侧的选择更好。
执行能力:差距最大的一点。 塑料碗的能力等于插件之和,我的 bot 的能力等于“一个会用 Linux 的人能做的事”。塑料碗不开放 Shell 的顾虑我理解,但这恰好说明了专用部署换来了什么。我认为这是两个项目最大的差别,也是我这边最好的一个取舍。
谁来决定说不说话:一个小模型,而不是主模型。 我和碗在一件事上是一致的:接不接话需要读懂群里在聊什么,规则做不到。分歧在“用哪个模型、给它看多少”。塑料碗用参与闸门、时间窗口和概率抽样把调用量压下来,但最后一步仍然要让主模型读一遍完整的上下文。我把这一步拆了出去:判断交给一个只看最近几行的分类模型,一次约 1,650 token,是一次主模型请求的五十多分之一,而且完全不碰主模型的前缀。“接不接话”需要的是读懂群里在聊什么,不需要人设和三个月的历史。
工具:一次声明,胜过按需发现。 塑料碗把大部分能力收进 execute,模型先 search、再 help、再 call,初衷是不让每轮请求带上全部定义。但我的分段数据里,工具 schema 只占请求的 1.1%,约一千 token,而且它在前缀里,按缓存价计费;反过来,每一次“发现”都是一次额外的模型往返,要把整个上下文再读一遍。按我的平均请求算,一次多出来的往返约等于九十次请求的 schema 开销,还要再加一轮延迟。工具定义放在稳定前缀里是最便宜的上下文,为了省它而增加往返,是在省最便宜的东西、花最贵的东西。塑料碗的能力比我多,它全量声明的 schema 会更大,但这个账的方向不变:先量一下 schema 占多少,再决定要不要为它付往返的钱。
记忆:越少机制,越少出错。 塑料碗有三层记忆:模型维护的 Memo Pad,sleeping 时整理的群聊事件摘要,人工审核的长期知识。它做得很认真,尤其是事件摘要带着 message-id 区间、能读回原话,原始消息也不删,这和 Raw-RAG 的结论一致。我选择一层都不加:原文存下来,模型不懂就去查。论文说明,加工过的记忆即使完整可访问,准确率也比不过原文;我自己的事故说明,加工过的记忆一旦写错,会被一遍遍继承。少一层机制,就少一类会自我复制的错误,也少一份每天都在花的整理 token。
代码:从源头少写。 塑料碗自己实现了 Context、调度、审计和 provider 层,代码不可能少;它的应对是让 agent 少读:AGENTS.md 按主题路由到 agent-doc/,历史设计和开发日志默认不读取。这是大代码库里很好的工程纪律。但我更愿意从源头少写:把会话、压缩、恢复、执行交给 Pi,不只是少了九千行代码,也意味着那三次和群聊无关的事故不再是我的问题。
也有碗做得更对的地方。
遗忘:碗的设计更干净。 塑料碗只删不摘要:每批注入的消息是一个 checkpoint,超过预算时从某个 checkpoint 整段丢掉之前的历史,上下文里的每一句都是原话,缓存只在 GC 那一刻断一次,也不花一次摘要调用。我那张压缩占比 45% 的周曲线,是它这套设计根本不会出现的事故。我仍然用摘要,是因为现在它由 Pi 维护、成本很低,而且只是锦上添花:真正兜底的是 bot 自己会去搜、会去翻原话。
让别人也能用:碗远远走在前面。 白名单、私聊和 Topic、管理面板、审计、虚拟群聊 Playground、备份。这些在我的 bot 上都没有。
为什么立足群聊
回到碗的问题:想做通用,为什么只服务一个群?因为场景决定了什么是“必要”。
通用框架最容易犯的错,是没有场景时什么都想要。我在 Memoh 上关掉的 37 个工具,每一个单独看都合理,放进群聊就是每轮多读一万多 token。只有立足一个具体场景,“这次读取真的必要吗”才有答案。
而群聊恰好是最严苛的那个场景:全天在线,多人同时说话,绝大多数消息与 bot 无关,回出去的话很短,读进来的上下文很长。输入是输出的 374 倍,每一处浪费都会被乘上十几万条消息。能在群聊里省下来的设计,放到客服、工作流、coding agent 里一样省;反过来就不一定了。
所以我的分工是:通用性交给平台,场景感留给自己这一层。 Pi 负责“怎么当一个 agent”,Docker 给它一台 Linux,我只写“怎么当一个群友”。
为什么说这有通用性
回头看,这篇文章其实是同一件事说了好几遍:
| 省什么 | 对谁 | 怎么省 |
|---|---|---|
| 调用次数 | 模型 | 不需要智力的判断交给确定性代码,只需要一点判断的交给只看切片的小模型;发完就结束;一次往返做完多件事 |
| 缓存命中 | 模型 | 前缀只追加不改写;动态信息有自己的位置;用测试守住字节 |
| 上下文 | 模型 | 窗口按计费方式选,不按上限选;重活放进子会话;保留原文,不懂就查 |
| 代码量 | coding agent | 通用部分交给平台;一个通用执行环境替代一堆插件;不留兼容层 |
在 LLM 应用里,token 是唯一的货币。成本是 token,上下文是 token,代码在 agent 眼里也是 token。省钱、省上下文、省代码,不是三个独立的美德,而是同一个约束在三个地方的投影。而省下来的东西不会消失,它们变成了更快的回复和更大的能力。
这不只是群聊 bot 的事。任何长期运行、反复调用模型的系统都在付同一笔账:每一次调用都在重读,每一个字节都会被反复读。一个顺手的设计,在高频场景里会被乘上几万次。
所以我的检查清单很短,每次改动之前问自己几句:
- 这一步需要模型吗?能不能用确定性代码做?真需要的话,需要的是哪个模型、给它看多少?
- 这次改动会不会改写已有的前缀?动态内容是不是只追加在后面?
- 加进上下文的东西,是不是每一轮都真的需要?它在请求里占多少?原文还在不在?
- 这段代码删掉会怎样?平台是不是已经有了?
最后
两个 bot 现在每天都在群里聊天,偶尔也会接上对方的话,接不接由各自的判断说了算。它们的写法很不一样:一个让小模型只看几行就决定开不开口,一个让主模型读完上下文再决定;一个会摘要,一个只会忘;一个什么都能干,一个什么都能配。但每次我和碗讨论一个新功能,第一个问题总是同一个:
这要多花多少 token?
能省一点,就再省一点。