1. 群聊 Agent 的账单为什么总是对不上
群聊里放进三个以上的 Agent,最先暴露的问题通常不是回答质量,而是账单归属:谁被点名、谁该回话、谁在没人叫它的时候偷偷轮询空转,日志里全糊成一团。本文把「默认静默、点名发言」这套群聊 Agent 编排落到可核账的层面,接入点在 TaoToken(https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_content=group_agent_ledger_intro),Base URL 统一填https://taotoken.net/api。先给群里的 Bot 申请独立的 Key,再去群里开静默守门,两件事顺序不能反。
事情起因很具体。群里两个机器人用了同一个 Key,日志里只有一串请求记录,月底打开用量页面看到总量翻了三倍,却完全分不清是「翻译 Bot 被动接话」还是「日报 Bot 定时空转」造成的。进一步排查发现,那个日报 Bot 每 60 秒轮询一次消息接口,发现没有点名就顺手把整段聊天记录丢给模型做「上下文理解」,一次几百 Token,一天两万多次,量就是这么堆出来的。
类似的经验在一场 Agent 实操分享里被总结成三条动作:能用 API 就别让 Agent 去模拟点击屏幕,同时配一个巡检机器人清理空转任务;把终稿和草稿做差分对比,把纠正逻辑固化成长期技能;群聊里多个 Agent 默认静默、只在点名时发言,登录 Cookie 按最小权限下发。这三条里,第一条治的是「无意义消耗」,第二条治的是「重复踩坑」,第三条治的是「权限越界」。而支撑这三条能长期跑下去的前提,是成本审计和权限隔离这两套制度——自动化系统垮掉,常常不是模型不够聪明,而是没人说得清钱花在哪、谁的权限太大。
这篇的落点很窄也很实:把群聊 Agent 的点名发言机制做成一个带账本的实现,产出一张「群聊点名发言 Token 统计表」,能明确标注哪个 Agent 消耗了多少 Token,并且把 Claude Code、Codex 这两条命令行链路也纳入同一套记账口径。
2. 先分账再编排:给每个群聊 Agent 一个独立 Key
多 Agent 共享一个 Key,是所有成本统计失效的根源。请求头里只带了同一串凭证,服务端用量视角看到的只是一个整体,你拿不到「谁花的」这个维度。所以第一件事是分账:一个 Agent 一个 Key。
拿到 Key 的入口在官网控制台,注册后进入 API Keys 页面创建。为了避免「同一个 Bot 在测试环境和群里混用」,建议按下面的命名规范来:
| Key 别名 | 绑定的 Agent | 用途 | 预算上限 |
|---|---|---|---|
grp-router-01 | 路由/守门 Agent | 只做点名判断,不生成正文 | 低 |
grp-writer-01 | 写作 Agent | 被点名后出正文 | 中 |
grp-review-01 | 审校 Agent | 只在被写作 Agent 点名时触发 | 中 |
grp-cron-01 | 定时/巡检 Agent | 只做清理与告警,不调大模型 | 极低 |
命名里的grp-前缀是关键,后面做日志聚合时可以直接按前缀切分。Key 创建后只在服务端环境变量里保存,群聊前端、共享配置文件、Git 仓库里都不出现明文。
这一步做完,你在用量页面上看到的就不再是一条总曲线,而是四条可以单独归因的曲线。后面所有的统计表、熔断、告警,全都建立在这个前提上。如果你还没建 Key,可以从官网入口进(https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_content=group_agent_key_setup),创建完再回来继续。
有一点要在团队里说清楚:分账不是把 Key 当作权限边界。Key 负责「钱算得清」,权限负责「事干得对」,两者要分开设计。群聊 Agent 该拿到的会话读取权限、该访问的频道范围,属于后半段要处理的问题。
3. 静默守门:让「被点名」成为唯一的调用入口
把「默认静默」落到实处,就是一条守门规则:收到群消息 → 判断是否点名 → 未点名则直接丢弃,连模型都不碰 → 点名则调模型,并把用量写进账本。这个顺序很重要,判断必须放在模型调用之前,否则「静默」只是名义上的。
下面是一段可以直接跑的 Python 示例,把守门、调用、记账三步串起来。它读环境变量里的 Key,请求发往固定的 Base URL,记账信息写进本地 JSONL 文件,方便后面用 SQL 聚合。
import json import os import time from openai import OpenAI # 每个 Agent 用自己的 Key,通过环境变量注入,不写在代码里 KEY_ALIAS = os.environ["AGENT_KEY_ALIAS"] # 例如 grp-writer-01 client = OpenAI( api_key=os.environ["TAOTOKEN_API_KEY"], # 占位:YOUR_API_KEY base_url="https://taotoken.net/api", ) LEDGER = "group_agent_ledger.jsonl" # 点名规则:@别名 或 "请 <别名> 回答" MENTION_PREFIX = "@" def is_mentioned(text: str, alias: str) -> bool: """只有显式点名才返回 True,其余一律视为静默""" if not text: return False if text.startswith(MENTION_PREFIX + alias): return True return f"请 {alias}" in text def ask_model(prompt: str, model: str = "claude-sonnet-4-5") -> dict: resp = client.chat.completions.create( model=model, messages=[{"role": "user", "content": prompt}], max_tokens=800, ) usage = resp.usage return { "text": resp.choices[0].message.content, "prompt_tokens": usage.prompt_tokens, "completion_tokens": usage.completion_tokens, "total_tokens": usage.total_tokens, } def handle_message(msg: dict, alias: str) -> dict | None: """msg 结构:{room_id, msg_id, sender, text, ts}""" trigger = "mention" if is_mentioned(msg["text"], alias) else "silent" if trigger == "silent": # 关键:未点名直接返回,不产生任何模型调用 return None started = time.time() result = ask_model(msg["text"]) record = { "ts": int(time.time()), "room_id": msg["room_id"], "msg_id": msg["msg_id"], "mentioned_agent": alias, # 被点名者 "responder_key": KEY_ALIAS, # 实际出钱的 Key "trigger": trigger, "latency_ms": int((time.time() - started) * 1000), "prompt_tokens": result["prompt_tokens"], "completion_tokens": result["completion_tokens"], "total_tokens": result["total_tokens"], "cost_estimate": round(result["total_tokens"] / 1000 * 0.003, 6), } with open(LEDGER, "a", encoding="utf-8") as f: f.write(json.dumps(record, ensure_ascii=False) + "\n") return {"reply": result["text"], "record": record} if __name__ == "__main__": demo = { "room_id": "room-42", "msg_id": "m-1001", "sender": "human", "text": "@writer-bot 把今天的需求评审结论整理成三条", "ts": int(time.time()), } print(handle_message(demo, "writer-bot"))这段代码里有三个设计点值得单独拎出来。
第一,mentioned_agent和responder_key是两个字段,不能合并。被点名的可能是「写作 Bot」,但实际发请求的可能是「审校 Bot 转发过来」的调用。归因要看responder_key,因为它才是真正花 Token 的那个凭证;mentioned_agent记录的是业务语义。两个字段都留着,统计表才能既回答「谁被叫得多」,也回答「谁花得多」。
第二,未点名时直接return None,不走任何网络请求。巡检机器人后面就是靠这个字段来判断有没有 Agent 在被静默期偷偷调用——账本里不会出现trigger=silent的请求记录,一旦出现就说明守门被绕过了。
第三,regular的定时任务不要走这条链路。定时 Agent 用自己独立的 Key(比如grp-cron-01),且只做本地清理和告警,不调用模型。能用 API 直接完成的事,不要让 Agent 去模拟点击屏幕,这条原则在群聊场景里尤其重要——模拟点击本身产生的中间请求,几乎无法纳入 Token 账本。
4. Claude Code 侧:用 settings.json 把用量口径固定下来
群聊 Bot 跑在服务端,但很多团队会用 Claude Code 在本地做同一套 Agent 逻辑的调试。这时候容易出现一个隐患:本地调试用的是一个 Key,线上跑的是另一个 Key,两边的模型名、超时、重试策略还不一样,最后统计表里的数字根本不可比。
Claude Code 的配置写在settings.json里,通过env字段注入 Anthropic 相关的环境变量。下面是一份把 Base URL 固定指向 TaoToken 的示例,注意ANTHROPIC_BASE_URL不要带尾斜杠:
{ "env": { "ANTHROPIC_BASE_URL": "https://taotoken.net/api", "ANTHROPIC_AUTH_TOKEN": "YOUR_API_KEY", "ANTHROPIC_MODEL": "claude-sonnet-4-5", "ANTHROPIC_SMALL_FAST_MODEL": "claude-haiku-4-5", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1", "AGENT_KEY_ALIAS": "grp-writer-01" }, "permissions": { "allow": [], "deny": [] } }几点说明:
ANTHROPIC_AUTH_TOKEN里直接填占位符YOUR_API_KEY,或者更稳妥的做法是留空,从系统环境变量里读,避免把 Key 写进会同步到 Git 的文件。
ANTHROPIC_SMALL_FAST_MODEL指向一个更便宜的模型,它承担的是标题生成、命令补全这类高频低价值请求。这部分用量往往是「看不见的支出」,单独指定小模型之后,主模型的账单会干净很多。
AGENT_KEY_ALIAS是自定义变量,Claude Code 本身不读它,但你在同一台机器上跑的记账脚本可以读,用来判断当前这个会话应该归到哪个 Agent 名下。
调试完成后,把本地产生的账本和线上账本按同一个responder_key维度合并,统计表才完整。
5. Codex 侧:config.toml 是另一条链路,别混用变量名
Codex 的配置体系和 Claude Code 完全不同,最容易犯的错误就是把ANTHROPIC_*这套变量名套到 Codex 上。Codex 读的是config.toml,走的是模型提供方(provider)配置,两者不能互相顶替。
一份可用的config.toml长这样:
model = "gpt-5" model_provider = "taotoken" [model_providers.taotoken] name = "TaoToken" base_url = "https://taotoken.net/api" env_key = "TAOTOKEN_API_KEY" wire_api = "chat" [profiles.group-writer] model = "gpt-5" model_provider = "taotoken" [profiles.group-cron] model = "gpt-5-mini" model_provider = "taotoken"这里用env_key指定环境变量名,Key 本身仍然放在环境变量里:
export TAOTOKEN_API_KEY="YOUR_API_KEY" export AGENT_KEY_ALIAS="grp-cron-01"需要注意,部分 OpenAI 兼容客户端会在base_url后面自动追加版本路径。如果调用返回 404,先把base_url换成带上版本路径的形式试一次,再回退到https://taotoken.net/api,不要凭猜测反复改。判断依据是错误体里给出的路径提示,而不是「试到能跑为止」。
Claude Code 和 Codex 两条链路并存时,用 CC Switch 一类的切换器管理会省事很多。它的三件套其实就是:Base URL、API Key、模型名。切换前先确认当前 profile 用的是哪套变量名——Claude Code 走ANTHROPIC_*,Codex 走model_providers+env_key。把这两套搞混,最典型的症状是「配置看起来没错,但请求根本没到网关」。
6. 产出物:群聊点名发言 Token 统计表
账本落成 JSONL 之后,用 SQLite 建一张表做聚合。下面这段 SQL 由你在本地执行,不涉及任何线上数据源:
CREATE TABLE agent_usage ( ts INTEGER, -- 时间戳 room_id TEXT, -- 群标识 msg_id TEXT, -- 消息标识 mentioned_agent TEXT, -- 被点名的 Agent responder_key TEXT, -- 实际发起调用的 Key 别名 trigger TEXT, -- mention / interval / manual latency_ms INTEGER, prompt_tokens INTEGER, completion_tokens INTEGER, total_tokens INTEGER, cost_estimate REAL ); -- 按 Key 别名汇总:谁是消耗大户 SELECT responder_key, COUNT(*) AS calls, SUM(prompt_tokens) AS in_tokens, SUM(completion_tokens) AS out_tokens, SUM(total_tokens) AS total, ROUND(SUM(cost_estimate), 4) AS cost FROM agent_usage WHERE trigger = 'mention' GROUP BY responder_key ORDER BY total DESC; -- 被点名次数 vs 实际消耗:看是否存在「叫得多但不贵」或反过来的 Agent SELECT mentioned_agent, COUNT(*) AS mentioned_times, SUM(total_tokens) AS total, ROUND(AVG(total_tokens), 1) AS avg_per_call FROM agent_usage WHERE trigger = 'mention' GROUP BY mentioned_agent ORDER BY total DESC;导入 JSONL 可以用一行命令完成:
sqlite3 group_agent.db <<'SQL' .mode json .import --skip 1 /dev/stdin agent_usage SQL jq -c '.' group_agent_ledger.jsonl | sqlite3 group_agent.db \ ".import /dev/stdin agent_usage"跑完之后,你会得到一张可以直接贴进周报的表,形如:
| 日期 | 群 | 被点名 Agent | 出账 Key | 调用次数 | 输入 Token | 输出 Token | 合计 | 估算成本 |
|---|---|---|---|---|---|---|---|---|
| 03-11 | room-42 | writer-bot | grp-writer-01 | 18 | 24,300 | 6,120 | 30,420 | 0.091 |
| 03-11 | room-42 | review-bot | grp-review-01 | 6 | 9,800 | 2,050 | 11,850 | 0.036 |
| 03-11 | room-42 | cron-bot | grp-cron-01 | 0 | 0 | 0 | 0 | 0 |
第三行是这张表最有价值的地方:grp-cron-01的调用次数是 0。如果某天它突然变成 40,说明定时 Agent 开始调模型了,要么是守门坏了,要么是新加的巡检逻辑偷偷接了模型。能明确回答「哪个 Agent 消耗 Token」,靠的不是总量,而是这张表里按 Key 别名分组后的零值。
再看两个衍生指标。第一个是「人均单次消耗」,写作 Agent 和审校 Agent 的上下文长度天然不同,如果写作 Agent 的单次消耗突然从 1.7k 涨到 8k,多半是有人把整段聊天记录都塞进了 prompt。第二个是「点名响应率」,被点名次数和实际调用次数应该接近 1:1,差得太多说明有的点名被漏掉了,或者一个点名触发了多个 Agent 同时抢答。
7. 巡检与熔断:把空转任务在花钱之前掐掉
有了账本就能做巡检。巡检 Agent 本身的定位是「不调大模型」,所以它的实现应当全是本地查询和规则判断:
import sqlite3 import time DAILY_BUDGET = 200_000 # 单个 Key 每日 Token 上限 SILENT_GRACE = 3600 # 静默期容忍窗口(秒) conn = sqlite3.connect("group_agent.db") cur = conn.cursor() today = int(time.time()) - 86400 # 规则一:超过日预算的 Key 直接告警 cur.execute(""" SELECT responder_key, SUM(total_tokens) AS total FROM agent_usage WHERE ts >= ? GROUP BY responder_key HAVING total > ? ORDER BY total DESC; """, (today, DAILY_BUDGET)) for key, total in cur.fetchall(): print(f"[BUDGET] {key} 已用 {total} tokens,建议降级到小模型或暂停") # 规则二:非点名触发却产生了消耗,说明守门被绕过 cur.execute(""" SELECT responder_key, trigger, COUNT(*) AS n FROM agent_usage WHERE ts >= ? AND trigger <> 'mention' GROUP BY responder_key, trigger; """, (today,)) rows = cur.fetchall() if not rows: print("[OK] 无越权调用") else: for key, trigger, n in rows: print(f"[LEAK] {key} 在 trigger={trigger} 下产生了 {n} 次调用,请检查守门逻辑") conn.close()规则二的告警阈值定得很死:非mention触发就应该为零。真出现正值,无非三种原因——有人直接调了 Bot 的 HTTP 接口绕过了群聊守门;某个定时任务被改成了调模型;或者守门判断里的别名和目标 Bot 的实际昵称不一致,导致点名识别失败后走了兜底分支。三种都能顺着responder_key直接定位到具体 Agent。
熔断的粒度建议按 Key 来,而不是按群。按群熔断会误伤,一个群里有五个 Agent,其中一个失控不该让其余四个也停下来。按 Key 熔断之后,把超限的 Key 对应的 Agent 降级到小模型,或者直接让它进入只读模式,等人工确认再恢复。这套机制也回应了权限隔离的思路——降级和熔断的开关不应该握在 Agent 自己手里。
8. 权限与常见报错排查
权限那部分遵循最小下发原则。群聊 Agent 需要的登录态,按「只读消息、只发指定频道」的范围授予,不要把管理员的完整 Cookie 复制给每个 Bot。上面第一条建议里提到「登录 Cookie 按最小权限下发」,落到操作上就是:每个 Agent 一套独立凭证,权限范围写清楚,过期时间短于业务周期。
接入过程中高频出现的几类报错,可以先按这张表自查:
| 现象 | 常见原因 | 处理方向 |
|---|---|---|
| 401 | Key 拼错、环境变量未加载、Key 已停用 | 打印变量前缀确认非空,重新从控制台取 Key |
| 404 | base_url路径不匹配 | 先用https://taotoken.net/api,再按返回体提示补版本路径 |
| 429 | 单 Key 并发过高 | 按 Agent 拆 Key,或对同一点名做合并去重 |
| 超时 | 单次 prompt 过大 | 限制携带的上下文条数,把长历史做摘要 |
| 消耗异常 | 守门被绕过、定时任务调模型 | 查账本中trigger <> 'mention'的记录 |
其中 429 在群聊场景下出现得比较隐蔽:一个点名消息被三个 Agent 同时看到,三个都认为该自己回答,于是并发三个请求。解决办法是在路由层加一个短窗口去重,同一条msg_id在 2 秒内只允许一个 Agent 出账。
还有一个细节容易被忽略:会话历史。群聊 Agent 如果每次都把最近 50 条消息带上,输入 Token 会随群活跃度线性增长。比较稳的做法是只带被点名消息及之前若干条与当前话题相关的记录,其余用摘要替代。这部分优化不需要改模型,改的是 prompt 组装逻辑,效果直接体现在统计表的输入 Token 列上。
如果你想先把单 Agent 的链路跑通、观察一次完整调用的用量结构,可以从模型对话入口试(https://taotoken.net/models/detail/chat?utm_source=taotoken_aicg_blog_end&utm_content=group_agent_trial),拿到真实返回里的 usage 字段,再对照账本里的字段做映射,比凭空设计字段靠谱得多。
9. 把成本审计变成默认设置
回到最初的问题:群聊 Agent 只点名发言,只是把调用次数压下来,它本身并不产生账单。真正让成本可控的是三件事同时成立——每个 Agent 有独立 Key,所以消耗可归因;守门在模型调用之前,所以静默是真的静默;账本每次调用都写,所以统计表可以复现。
这三件事都不复杂,难的是让它们成为默认值而不是临时措施。实践顺序可以这样排:先给已在群里的每个 Bot 拆出独立 Key,把别名写进配置文件;再在消息入口加一层点名判断,让未点名的消息在调用之前就被拦截;接着把每次调用的 usage 落盘;最后把上面那段巡检脚本挂成定时任务,按日检查预算与非点名调用。四步走完,统计表就是日常产物,而不是出问题之后回头补的作业。
命令行方向如果要继续扩展,Codex 那条链路照着config.toml把 provider 固定住即可,关键是别把两套环境变量混用。需要更细的接入说明和参数含义时,可以直接看 Claude Code 文档(https://taotoken.net/doc/ClaudeCodeAnthropic?utm_source=taotoken_aicg_blog_end&utm_content=group_agent_doc),里面按要求配置 base_url 与鉴权项就能跑通。
按量用、按需扩,先把 Key 拆开,再把守门加上,最后让巡检脚本每天替你问一句:今天哪个 Agent 花的 Token,值不值。