千笔-AIWritePaper · https://www.aiwritepaper.com
Agent 能跑通,不等于成本账能平。生产里最常见的五类事故是:只抄最后一轮 token、流式还没消费完就入库、as_tool子 agent 用量漏计、用启发式估算代替账单、超预算后静默截断上下文还继续跑。它们都不会立刻抛异常,却会在月末账单和上下文完整性上一起爆。
本文在openai-agents 0.23.1下核对Usage/RunContextWrapper.usage字段与累计语义,并用假模型固定用量做离线 smoke:注入子 agent 漏计、流式中途记账、超预算仍继续三类故障,各留日志与 CSV(_w/usage-cost-smoke/)。不打真实计费 API。
图:上排为每次 run 可读字段;中排为累计路径与预算闸门;底部为五条生产禁区。
目标说明
读完你应能独立完成五件事。
- 从
context.usage(或等价的 run 上下文)读出请求数、输入/输出/总 token,以及缓存与推理细分。 - 在多轮、handoff、
as_tool子 agent 场景用Usage.add(或共享同一 usage 对象)做整次 run 累计。 - 流式场景等到最后一块消费完再记账;中途读数视为过时。
- 实现按 run 的 token 预算闸门:超限拒绝,禁止静默截断后续跑。
- 把五条失败样本写进禁区表与 CI。
适用与边界
适合:已在 Agents SDK 里跑多 agent / 工具子 agent,需要按次 run 做用量审计与成本上限。
不讲:tool 循环与超时、MCP、guardrails、lifecycle、ModelSettings、RunContext 业务字段、streaming 事件协议细节、handoffs 路由、tracing、ModelProvider、LocalShell、needs_approval、tool_use_behavior、SQLiteSession、AgentsException、托管 WebSearch/FileSearch(见既有工程笔记)。本篇专写用量统计与成本闸门。
失败含义:smoke 用固定Usage对象验证累计与闸门逻辑;真实账单以平台结算为准。类名与字段来自本机openai-agents 0.23.1。
机制:从哪里读、怎样累计、何时完整
| 字段 | 含义 | 工程注意 |
|---|---|---|
requests | 对本轮 LLM API 的请求次数 | 多轮会累加 |
input_tokens/output_tokens/total_tokens | 跨请求合计 | 账单主字段用累计值 |
input_tokens_details.cached_tokens | 缓存命中输入 token | 可为 0;勿当「免费」就忽略审计 |
input_tokens_details.cache_write_tokens | 写入缓存的 token | 与 cached 分开记账 |
output_tokens_details.reasoning_tokens | 推理类输出细分 | 有的模型为 0 |
request_usage_entries | 每次请求的拆条目 | add()会保留,便于分请求成本 |
读数入口:RunContextWrapper.usage。文档写明,流式响应在最后一块处理完之前,usage 是过时的。因此「流式中途打印 token」不能当结账依据。
累计:同一 run 内多次模型调用应落到同一Usage上。Usage.add(other)会累加请求数与各类 token,并合并request_usage_entries。as_tool子 agent 在 SDK 里通过 fork 共享父级 usage 引用;你若在外层自己开子 Runner,必须显式把子结果 usageadd回父级,否则就是 F3。
fromagents.usageimportUsage,InputTokensDetails,OutputTokensDetails run_usage=Usage()run_usage.add(parent_turn_usage)run_usage.add(as_tool_child_usage)# 漏掉这一行 = 子 agent 漏计# 预算闸门:用累计 total_tokens,不用估算ifrun_usage.total_tokens>MAX_TOKENS_PER_RUN:raiseRuntimeError("token budget exceeded")预算闸门建议钉在「整次 Runner.run / run_streamed 结束且流已耗尽」之后、对外交付之前。超限动作只能是拒绝或降级到人工,不能静默截断历史消息继续自动跑。
离线 smoke:5 条通过 + 5 条失败
P1 PASS 假模型固定 Usage:请求数与 token/缓存/推理细分可读 -> req=2 in=1200 out=300 total=1500 cached=400 cache_write=50 reasoning=80 P2 PASS 多 agent/as_tool:父+子 Usage.add 累计且保留 request_usage_entries -> req=2 total=880 entries=2 cached=40 P3 PASS 流式:未消费完 usage 过时;最后一块后才完整 -> mid_total=0 complete=True final_total=1020 P4 PASS 按 run 的 token 预算闸门:未超限放行 -> allow=True billed=1000 max=2000 action=continue P5 PASS 账单以累计 Usage.total_tokens 为准,估算不得替代 -> estimated=5000 billed_usage=880 use_estimate_as_bill=False F1 FAIL 禁区:只看最后一轮用量 -> last_only=670 true_total=1120 undercount=450 F2 FAIL 禁区:流式未消费完就记账 -> booked_early=0 stream_complete=False stale=True F3 FAIL 禁区:as_tool 子 agent 用量漏计 -> parent_only_total=600 child_missed=280 true_should_be=880 F4 FAIL 禁区:把估算当账单 -> estimate_as_bill=5000 actual_usage=1500 mismatch=True F5 FAIL 禁区:超预算后静默截断上下文仍继续 -> over=True allow=True silent_truncate=True billed=3500 action=continue_truncated CSV rows: 10逐条读法。
- P1:固定假用量可读出 requests、in/out/total、cached、cache_write、reasoning。
- P2:父 600 + 子 280,累计 total=880,entries=2,缓存细分一并相加。
- P3:流式中途
mid_total=0(过时);消费完后final_total=1020。 - P4:预算 2000,实际 1000,放行。
- P5:估算 5000 与真实累计 880 分离,禁止用估算代替账单。
- F1:只看最后一轮 670,真实合计 1120,少计 450。
- F2:只消费 1/4 块就记账,
booked_early=0且stale=True。 - F3:只计父 600,子 280 漏计。
- F4:把估算 5000 当账单,与实际 Usage 1500 不一致。
- F5:超预算 3500>2000 仍
allow=True且silent_truncate=True。
生产禁区表
| 禁区 | 失败含义 | 正确动作 |
|---|---|---|
| 只看最后一轮用量 | 前面轮次成本消失 | 读整次 run 的累计usage |
| 流式未消费完就记账 | 入库数字过时或为 0 | 等最后一块处理完 |
as_tool子用量漏计 | 子调用「免费」幻觉 | 共享 usage 或显式add |
| 估算当账单 | 财务与工程对数不上 | 只认 Usage / 平台账单 |
| 超预算静默截断续跑 | 上下文残缺仍对外答 | 闸门拒绝或转人工 |
最小接入示例(读数 + 闸门)
把闸门挂在 Runner 返回之后:先取result.context_wrapper.usage(字段名以你使用的结果对象为准),再调用TokenBudgetGate.check。若你使用run_streamed,必须先异步迭代至流结束,再读 usage;否则会落入 F2。对含as_tool的图,在返回前断言usage.requests不少于「父模型调用次数 + 子模型调用次数」的下界,作为漏计的廉价探针。
本地联调可用假模型固定 Usage:每次响应带上已知的 input/output,跑完后检查累计是否等于各次之和,并故意丢掉子段add让 F3 复现。通过后再接真实模型,避免一上来就被缓存命中波动干扰断言。
验收清单
- 每次 run 结束后持久化
requests/input/output/total与缓存、推理细分。 - 多 agent / handoff /
as_tool场景核对累计是否等于各段之和。 - 流式路径在
complete之前禁止写账单表。 TokenBudgetGate超限返回 reject,测试里断言不存在continue_truncated。- CI 跑
smoke.py,P1至P5 保持 PASS,F1至F5 保持 FAIL。
跑 smoke 前确认本机已安装openai-agents==0.23.1:脚本只依赖agents.usage的数据类,不发起网络请求。把smoke_output.txt与 CSV 一并归档,换小版本 SDK 时先看Usage字段是否仍含缓存与推理细分,再决定是否更新断言。
当天 30 分钟落地
- 5 分钟:在现有 Runner 回调里打印
context.usage.total_tokens(非流或流结束后)。 - 10 分钟:给一次含
as_tool的 run 做父/子分项与合计对照。 - 10 分钟:接入
TokenBudgetGate,用固定假用量打超限用例。 - 5 分钟:把 F1至F5 写进 oncall 手册。
流式取消与中途快照
用户或网关取消流式生成时,已发出的 chunk 可能已经产生部分 output tokens,但RunContextWrapper.usage在最后一块处理完之前仍可能过时。正确做法是:取消路径也要走到「流关闭/耗尽」钩子,再读取一次 usage 作为不完整 run的审计记录,并打上cancelled=True。错误做法是:把取消前某次日志里的total_tokens=0或中间值写成正式账单,或因为取消就整行删除用量(平台侧仍可能计费)。
F2 用「只消费 1/4 块就记账」模拟这一类事故:booked_early=0且stream_complete=False。CI 里应断言:账单写入函数的前置条件包含stream_complete(或非流路径的run_done)。
缓存 token 要不要进预算
cached_tokens与cache_write_tokens进入input_tokens_details,不一定与「向用户报价」的口径相同,但预算闸门默认应看total_tokens(或平台定义的计费 token),而不是只看非缓存输入。若产品政策允许缓存优惠,应在闸门外单独配置「计费口径」,并在 CSV 里同时留下 raw Usage 与计费口径两列,避免事后无法对账。smoke 的 P1 要求缓存与推理字段可读,不等于要求它们从预算中豁免。
与 oncall 手册的衔接
告警建议至少三条:单 runtotal_tokens超预算、同日累计超日预算、as_tool调用次数与requests增量明显不匹配。前两条直接绑TokenBudgetGate;第三条用 entries 条数或子调用计数做启发式,命中后人工抽查是否漏add。手册里写清:估算值只用于容量规划,不用于客户账单与拒绝决策。
踩坑
- 用「大约几千 token」做额度申请,月末才发现 Usage 合计翻倍。
- 取消流式生成后仍把中途快照当最终用量。
- 子 agent 单独 Runner 跑完不回写父 usage。
- 超限时删掉最早的 tool 结果再重试,还不记截断事件。
- 只监控 output_tokens,忽略巨大的 input 与缓存写入。
假模型固定用量怎么对照账单字段
对照时先固定金标准:一次 run 结束后的Usage快照,含requests、input_tokens、output_tokens、total_tokens,以及cached_tokens、cache_write_tokens、reasoning_tokens。若启用了request_usage_entries,还应能按请求拆开复核。假模型侧每次调用返回写死的 Usage,便于断言加减;真实平台延迟与缓存命中率会变,但字段名与累计规则应保持一致。
实践上建议把成本终裁拆成三道门。第一道:流是否已耗尽(或非流路径已返回)。第二道:父 run 与所有as_tool/handoff 子段是否都已add。第三道:累计total_tokens是否小于本 run 预算。三道都过,才允许写入账单表或触发下游计费事件。任何一道失败,输出停在草稿区,禁止用「大概没超」作结案理由。
与「托管检索选型」「异常恢复」「会话持久化」分开记账:本文只验收用量对象与预算闸门。检索是否抽检、异常是否可恢复,各自用各自的 smoke,避免一篇工程笔记塞满所有 SDK 主题。
预算闸门伪代码与拒绝语义
@dataclassclassTokenBudgetGate:max_total_tokens:intdefcheck(self,usage:Usage)->str:ifusage.total_tokens>self.max_total_tokens:return"reject"# 禁止 continue_truncatedreturn"continue"拒绝后的产品行为可以是:返回明确错误码、降级到更小模型的新run(新预算、新 usage)、或转人工。不允许的行为是:删掉上下文前缀使 total 表面下降,却继续同一 run 编号对外回答。F5 专拦后一种「看起来还能跑」的静默截断。
若业务必须做上下文压缩,应开新 run:旧 usage 冻结入库,新 run 使用压缩后的输入并重新累计。这样财务与审计仍能把两次 run 分开看,而不是在同一次自动循环里把截断藏掉。
多 agent 累计的两种合法写法
写法一:SDK 默认的共享引用。as_toolfork 让子调用直接往父usage上累加,父侧结束时读一次即可。写法二:子 Runner 独立 usage,结束后parent_usage.add(child_usage)。两种都可以;不合法的是「子跑完只把文本结果拼回去,usage 丢在子作用域」。handoff 换 agent 后同样要确认仍落在同一 run 上下文的 usage 上,而不是每个 agent 各记一份末轮快照却从不汇总。
排查漏计时,用request_usage_entries的条数与requests对照:若请求次数对得上但 total 偏小,多半是某次add被跳过;若 entries 偏少,说明有请求从未进入累计对象。
为何不能只监控 output
不少团队面板只画 output_tokens,因为「生成字数」好懂。实际成本与上下文风险往往由 input 主导:工具结果回灌、历史消息、缓存写入都会推高input_tokens。预算闸门与告警应以total_tokens(或平台计费口径)为主,output 仅作辅助序列。P1 把 in/out/total 与缓存、推理一并打印,就是为了避免单一指标盲区。
总结
用量统计的入口是每次 run 上下文上的Usage:请求数、输入/输出/总 token,以及缓存与推理细分;add与共享引用负责多 agent/as_tool累计;流式必须消费到最后一块才完整。成本闸门按累计total_tokens做硬拒绝。五条禁区:只看末轮、中途记账、子用量漏计、估算当账单、超预算静默截断:在离线 smoke 中全部 FAIL。把同一脚本挂进 CI,换模型时复跑,而不是凭感觉调额度。
用量与预算闸门日志可用千笔-AIWritePaper 整理归档;骨架与脚本不依赖产品名。