我们群里住着两个 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,住在同一个群里,三个月的总账是这样的:

三个月的账本2026-08-07 — 2026-11-04 · 两个 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 prompt10.2%10.7%
压缩摘要2.1%0%
工具 schema1.1%21.3%

本项目:40,593 次请求的平均值,平均每次 93,067 token。Memoh:改造前抓的单次请求,按字节计。

这张表决定了后面所有的优先级:真正的大头是上下文有多长,以及它能不能命中缓存。

成本可以写成:

C=N×T×[(1−h)+h×r]C = N \times T \times \left[(1-h) + h \times r\right]

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.510.880.841.000.53
说明加 14 条例子,阈值 0.450.920.820.750.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)去套我三个月的输入量:

同样 53.4 亿输入 token,按 DeepSeek V4 Flash 计价
缓存命中率输入费用
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 的成本:

256K 窗口相对 16K 的每 token 生成成本
0.0×2.0×4.0×6.0×8.0×GLM 5.2Kimi K2.6Qwen 3 Coder Next7.0 · Qwen 3 Coder Next

来源: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 在旧部署里把两头都走过一遍,账本记下了两种代价:

旧部署每周的平均输入长度与压缩调用占比
0501001500204060平均输入(千 token)压缩调用占比(%)

平均输入不含压缩调用本身。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 循环怎么处理“帮我查查这个”:

  1. 模型读完上下文,调用 search 查第一个关键词;
  2. 结果回来,模型再读一遍全部上下文,决定再查第二个;
  3. 结果回来,再读一遍,决定读其中一个网页;
  4. 再读一遍,终于 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/ 的代码行数
05000100001500020000行数 8089

每个日期取当天某个提交的 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 的事。任何长期运行、反复调用模型的系统都在付同一笔账:每一次调用都在重读,每一个字节都会被反复读。一个顺手的设计,在高频场景里会被乘上几万次。

所以我的检查清单很短,每次改动之前问自己几句:

  1. 这一步需要模型吗?能不能用确定性代码做?真需要的话,需要的是哪个模型、给它看多少?
  2. 这次改动会不会改写已有的前缀?动态内容是不是只追加在后面?
  3. 加进上下文的东西,是不是每一轮都真的需要?它在请求里占多少?原文还在不在?
  4. 这段代码删掉会怎样?平台是不是已经有了?

最后

两个 bot 现在每天都在群里聊天,偶尔也会接上对方的话,接不接由各自的判断说了算。它们的写法很不一样:一个让小模型只看几行就决定开不开口,一个让主模型读完上下文再决定;一个会摘要,一个只会忘;一个什么都能干,一个什么都能配。但每次我和碗讨论一个新功能,第一个问题总是同一个:

这要多花多少 token?

能省一点,就再省一点。