ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

LLM API调用如何避免天价token账单:上下文管理与验收流程优化实践

LLM API调用如何避免天价token账单:上下文管理与验收流程优化实践 看到账单的那一刻我整个人是懵的。92 次请求烧掉 1340 万 token换算成费用足够日常跑好几天的普通业务。更让我无语的是这 92 次请求没有一次触发模型故障功能验收也全部通过整个过程只花了不到两个小时。慢的从来不是 AI是我的验收流程——这句话当时我写在了复盘文档第一行。这次踩坑让我重新把整套 LLM API 调用、token 计费、会话管理和验收设计从头过了一遍。中间拆出来的数据正好对得上标题里那串等式91251415128854。如果你也在做 AI 应用联调、接口验收或者 AI Agent 测试这篇文章可以帮你少交点学费。1. 一次让人血压飙升的验收92 次请求烧掉 1340 万 token1.1 现象模型没拖后腿脚本在放火先说现场。当时我负责一个基于大模型的多模块业务验收流程大概是把 8 个功能场景串在一起跑每个场景都调用一次 Chat Completion 接口最后校验返回结果。从功能角度所有断言都通过了模型响应也基本在预期范围内。但等我把接口网关的计量日报拉出来看到消耗数字整个人愣住了92 次请求1340 万 token平均单次请求约 14.56 万 token。这个数字非常不健康。正常的单轮问答输入加上输出一般几千 token 到一两万 token 就了不起了。单次请求 14.56 万 token 意味着每次调用都在拖着巨大的上下文跑而模型侧真正的计算时间也被这些冗余输入拖慢了。API 响应时间看起来从 1 秒变成 8 秒、12 秒很多人第一反应是“模型不行”但拆开看你会发现慢的根源是请求体太大、请求次数太多、校验逻辑重复。更关键的线索是那串数字91251415128854。这是我拆出来的 8 个场景分别引入的 prompt 增量单位是万 token。也就是说如果每个场景都只带自己的输入一轮完整验收的新增输入大约是 91 万 token。92 次请求却烧出 1340 万 token说明真正进入模型的上下文远不止这 91 万大量历史消息被反复携带、叠加、重放。1.2 慢在哪验收循环把上下文当“传家宝”问题出在我验收脚本里的一个低级但极其常见的错误全局共享 messages。出于省事我定义了一个数组把每个场景的用户输入和模型输出都 append 进去下一次请求继续复用同一个数组。听起来很自然但实际上每一次新请求都会把前面所有场景的问答历史全部发给模型。打个比方你本来只是想问老师一道新题目但你把过去一个学期所有作业、订正、考试错题全部搬到了老师桌上老师每看一道题都要耗费大量时间翻阅前面几十道题。模型虽然没有“翻书”这个动作但它要对所有这些 token 做 prefill 和注意力计算输入越长首字延迟越高费用也成倍增长。这还不是最可怕的。由于外层还有一个验收循环某些失败的场景会触发重试重试时又把之前所有历史再复制一份。92 次请求里面有相当一部分是重复请求、重试请求和高负载请求。结果就是单轮应该只花 91 万 token 的事情最终烧掉了 1340 万 token。真实的模型生成速度并没有变慢是请求体量把模型硬生生拖住了。2. 烧掉的 token 都去哪了一次消耗拆解2.1 token 计费的基本盘输入、输出、缓存与重试要理解这次浪费先要看懂大模型 API 的 token 计量。绝大多数平台按输入 token、输出 token、缓存 token 分别计费而且不同计费项单价不同。输入 token 通常是模型读取的所有内容包括系统提示词、历史对话、当前用户输入、工具返回结果输出 token 是模型生成的内容缓存 token 则是命中了上下文缓存、计费折扣较高的部分。很多人容易忽略的是同一个 token 在多轮对话中会被重复计费。历史对话并不会因为已经生成过就自动免单只要你还把它塞进下一次请求它就会再次进入输入计费。这也是为什么“共享上下文”是 token 消耗失控的第一杀手。更隐蔽的是重试机制如果请求超时或遇到限流脚本选择重试那这次请求的输入 token 会原封不动再计一次费直到成功为止。我这次踩坑里92 次请求中真正不重复的接口调用其实只有 8 个场景加少量辅助请求其余都是历史累积和重试带来的“超额部分”。一次请求如果带着 50 万 token 的历史哪怕只调用一次费用就已经非常可观如果失败重试三次等于三倍消耗。2.2 我的清单251415128854 到底代表什么我把一次业务验收拆成了 8 个场景每个场景都有明确的任务类型也都有各自需要喂给模型的输入内容。从网关日志里提取每次请求相对上一次请求的新增 token 量就能得到这组数字场景新增 prompt token 量说明知识库问答25 万携带大量检索召回文档意图识别14 万拼接了一批历史会话样本文档要素抽取15 万输入一整份长文档原文内容改写12 万原文加改写要求安全审查8 万多规则提示词加待审内容摘要生成8 万长文本输入翻译质量5 万双语对照情感分析4 万批量化评论数据这 8 项合计 91 万 token这就是“单轮新增输入”的真实体量。如果脚本处理得当每轮验收的总 token 消耗应该大致在 91 万加上输出 token 的范围内。但实际数字是 1340 万等于新增输入被放大了将近 14.7 倍。放大来源就是那些“历史遗留”的 messages第一个场景产生的对话历史被第二个场景继承第二个场景的又继续继承下去到第八个场景时请求体里已经滚起了 91 万 token 的存量后面再跑任何验证都是在背着这座山走。这里要注意新增 token 和请求总 token 是两回事。新增只看“这一次比上一次多了什么”请求总 token 是“这一次到底送了多少进模型”。我之前只记录了新增量没有记录请求总量导致问题拖到账单出来才暴露。2.3 放大消耗的四个隐形杀手除了共享上下文还有四个我没注意到的消耗放大器。第一个是失败重试。我在脚本里对超时异常做了简单重试最多重试 3 次但没有区分错误类型也没有退避策略。一旦某个大请求超时脚本立刻用同样的超大体量再发一遍等于一次失败变成三倍消耗。最离谱的一次我连续重试了 5 次才成功单次请求烧掉了将近 200 万 token。第二个是日志与镜像。为了排查方便我在每次请求前后打印了请求体和响应体还把部分响应同步写入了调试库。这些日志和数据库记录本身不直接消耗 API token但它们让我误以为所有数据都有必要保留于是没有及时清理历史 messages。实际上日志应该只保留必要字段而不是把完整会话都堆在内存里。第三个是测试数据未裁剪。当时为了“真实”我直接把生产环境导出的大段文档和聊天记录塞进了 prompt。单份文档动辄几万字一个场景就可能消耗数万 token。测试阶段完全可以用抽样、截断、合成数据代替没必要拿全量生产数据做冒烟验证。第四个是输出长度没有上限。部分场景我没有设置 max_tokens模型在自由生成时输出了大段解释性内容把这些输出再次 append 到历史后下一次请求的体量又大了一圈。输出 token 同样计费而且会递归式地增加未来的输入 token。3. 为什么慢的不是 AI而是验收流程3.1 AI 的实际响应时间被谁拉长大模型接口的延迟主要由两部分组成prefill 阶段和 decode 阶段。prefill 阶段处理全部输入 token输入越长这一步耗时就越大decode 阶段逐个生成输出 token生成的数量越多耗时越长。在我那次验收里92 次请求的平均输入体量已经远超正常水平很多请求的输入长度在数十万 token 级别。模型服务端光是读取并处理这些输入就需要数秒甚至更久再加上排队等因素单次请求的墙钟时间被拉得很长。表面看是“模型响应慢”实际上是请求体量异常导致的必然结果。还有一层原因大量重复请求会占用并发配额。92 次请求里如果有几十次都是无效的重试和重复验证那这些请求就是纯纯的干扰。真正需要通过的请求反而可能排队等更久。3.2 验收流程设计的三宗罪这次事故让我总结出验收流程设计的三宗罪。第一宗罪全部用手工脚本缺少用例分层。我没有区分“冒烟用例”和“全量用例”每个场景都走同样的完整 prompt、同样的生产数据、同样的真实模型。正确做法是先用轻量模型或 mock 数据把所有链路跑通最后再用真实模型和抽样数据做全量验收。第二宗罪断言太靠后。我通常在模型返回完整结果后才做断言校验这会导致所有输出 token 都计费完哪怕结果完全不合格。后来我发现可以启用流式接收并配合提前终止条件一旦生成内容已经明显偏离预期就中断请求节省大量输出 token。第三宗罪没有成本护栏。整个验收过程没有设置 token 预算上限没有告警也没有单次请求体量的硬限制。网关日报是事后看的账单也是事后看的等到发现异常时钱已经烧掉了。只要在代码里加一个简单的 token 预算检查就能在请求发出前拦住问题。3.3 一个“健康”的验收流程应该回答三个问题那次之后我给自己定了一个规矩任何一次大模型相关验收在设计阶段必须先回答三个问题。第一个问题这次请求是否必要能不能用 mock 数据、批量合一或者固定样例来代替如果只是验证“链路通不通”完全不需要真实调用大模型。第二个问题能否把上下文裁剪到最小只保留本次场景真正依赖的输入内容删除历史消息、冗余重复文本和无关日志。第三个问题如果请求失败我应该做什么是跳过、重试、降级还是直接失败退出重试必须配合指数退避和 jitter而且要限定请求体量不能让一个超大请求无限重发。把这三个问题写成验收清单之后我发现大量“看起来必须做”的调用其实都可以优化掉。比如场景 A 的结果只是给场景 B 提供一条摘要那我完全可以把大模型生成的摘要存下来场景 B 只引用摘要而不是把场景 A 的完整长文档继续带过去。4. 把 token 消耗打下来的实操方法4.1 第一步给请求“称重”给流程“装水表”优化 token 消耗的第一步不是改代码而是先建立计量意识。你需要知道每次请求的输入 token、输出 token、缓存命中 token 分别是多少以及这些指标在单个用例、单轮验收、全量回归中的变化趋势。具体做法是在请求封装层统一记录 token 用量。大模型 SDK 的响应对象里通常带有 usage 字段包含 prompt_tokens、completion_tokens、total_tokens。把这些字段统一打印或上报到日志系统再按场景聚合就能看到真正的消耗分布。我现在的习惯是每次调用都做一次前置检查def num_tokens_from_messages(messages, modelgpt-4): # 实际可用 tiktoken 或对应 tokenizer total sum(len(tokenizer.encode(m[content])) for m in messages) return total budget_per_request 30_000 for scenario in scenarios: if num_tokens_from_messages(messages) budget_per_request: raise RuntimeError(ftoken budget exceeded: {scenario.name}) # 继续调用这里的核心思路是“请求前称重、请求后记账”。如果单次请求的输入 token 超过设定阈值直接失败不把请求发出去。阈值根据模型上下文窗口和业务需要来定宁可调小一点。4.2 第二步用会话隔离代替共享上下文那次的根因是全局共享 messages对应的修复方案是所有用例之间做会话隔离。每个场景独立构造自己的 messages 列表系统提示词可以共用但用户输入、历史消息、工具结果一律不复用。system_prompt {role: system, content: 你是一个业务验收助手} scenarios [ {name: 意图识别, prompt: ...}, {name: 摘要生成, prompt: ...}, # ... ] for scenario in scenarios: messages [system_prompt, {role: user, content: scenario[prompt]}] resp client.chat.completions.create( modelyour-model, messagesmessages, max_tokens512, ) check_result(resp)注意多轮对话时也未必不能共享历史但一定要有“遗忘机制”。比如滑动窗口只保留最近 N 轮对话或者把前面的内容做一次摘要压缩后再传给下一轮。内部对话继续携带完整历史可能没问题但外部验收场景里每轮用例应当是原子的。会话隔离最大的好处是问题容易复现。某个场景失败时你打印的就是这个场景的完整请求而不是前面所有场景的堆叠。4.3 第三步引入分级验收与干跑模式不是所有测试都必须调用完整模型。我现在会把验收分成三层第一层是“干跑”只校验参数、格式、鉴权和基础链路不真实调用模型或者用固定 mock 响应。这一层可以在代码提交阶段跑耗时秒级成本为零。第二层是“冒烟验收”用轻量模型或较低档位的模型跑一遍关键路径输入数据用裁剪后的样例目的只是确认各个场景能正常返回并通过断言。第三层才是“全量回归”使用真实生产模型、抽样的真实数据在夜间或者其他低成本时段执行并设置严格的 token 预算。分层之后日常验收每天可以跑十几遍也不心疼全量回归则一周跑一次。真实模型只在最后触发token 消耗立刻下降几个量级。4.4 第四步重试、并发与流式收敛重试不能无脑做。我现在的策略是网络错误和限流错误可以重试但采用指数退避第一次等 1 秒第二次 2 秒第三次 4 秒最多重试 3 次。由于内容审核、参数错误导致的 400 类型错误不重试直接失败。每次重试前重新统计 token如果请求体量超过阈值先裁剪再重发。并发也要控制。批量场景同时发出 20 个请求可能瞬间打满配额触发限流后又引发重试最后 token 没少烧时间还更长了。串行加少量并发才是最经济的方式。流式接收stream值得重点推荐。流式响应本身不减少 token 用量但它让你有机会提前中止生成。用流式方式接收增量内容同时做断言判断一旦发现开头已经不符合预期立刻调用中断避免生成剩余大量输出 token。对于输出较长的场景这套方法省得非常明显。下面是优化前后的对照项目优化前优化后单轮验收请求次数92 次8 次另加少量降级调用平均单次输入 token约 14.56 万约 0.9 万输出 token 上限未限制512~1024重试策略无退避无限重试指数退避最多 3 次会话历史全局共享用例隔离成本护栏无请求前预算检查5. 踩坑实录从报错到失控的排查笔记5.1 常见报错速查表那次验收过程中还不只是 token 消耗问题各类报错也在连环轰炸。很多报错表面上和“烧钱”无关实际上都在推高请求次数和消耗。这里整理一张速查表报错特征可能原因处理方式401 Unauthorized / token 失效API Key 或 access token 过期刷新 token避免在循环内部刷新导致重试放大403 Forbidden权限不足或地区限制检查账号权限、接口白名单禁止盲目重试429 Too Many Requests并发超限或触发限流降低并发指数退避配合 jitter400 bad request: invalid refresh_tokenrefresh token 过期或为空重新走 OAuth 授权流程更新缓存API 请求失败 443网络连接异常区分瞬时错误和持久错误瞬时错误退避重试failed to refresh token刷新令牌端点异常不要在同一请求循环里重复刷新加锁或缓存sign-in could not be completed令牌交换链路失败检查回调参数、过期时间、授权码单次使用限制这里最容易踩的坑是token 失效后脚本没有失败退出而是在循环里反复刷新、反复重试。每刷新一次就可能产生新的网络请求每重试一次就重新发送整个巨型 prompt。看起来是在“处理异常”实际是在加速烧钱。5.2 一次 403 引发的“连环烧钱”我复盘时发现了一个典型的连锁场景某个用例在会话中途遇到 403 权限错误脚本捕获异常后没有区分错误类型直接进入重试逻辑。重试时仍然带着同一份巨大的历史消息403 依旧出现脚本又继续重试直到重试次数耗尽。这个用例本身只应该消耗 3 万 token最后却消耗了 130 多万 token。更糟的是因为外层循环没有 catch 住异常后续用例继续执行而 messages 里又堆积了失败请求的信息。每往下走一个用例请求体量就更庞大一次最终整个验收过程变成了一个不断膨胀的重试循环。排查方法很简单在 gateway 层给每个请求打上 request_id再给每次重试打上同一个 parent_id。这样能从日志里把一条请求链路完整拉出来看到它重试了几次、每次的 token 是多少。对比之后就会很直观地发现大量消耗集中在少数几个异常重试链路上。5.3 守住钱包的三个护栏经历过这次事故我给自己定了三个护栏现在基本不再出现 token 失控的账单。第一个护栏是预算告警。在网关层维护一个计数器每次请求后累加 total_tokens超过单日预算就触发告警超过硬上限直接熔断不再放行任何请求。第二个护栏是 token 快照。每个用例执行前后都打印 usage 快照计入测试报告一旦某个用例的 token 相比基线上涨超过 20%自动标红。第三个护栏是环境隔离。验收环境、预发环境、生产环境用不同的模型档位和不同的 token 限额绝不在验收环境里跑生产级的长文档全量数据。这三个护栏实现起来都不复杂核心是让 token 消耗从“事后看账单”变成“事前拦、事中查、事后对比”。6. 这套验收方案还能怎么扩展6.1 从 API 验收延伸到 AI Agent 评测如果你不只是调单个 Chat Completion而是验收一个 AI Agent那 token 管理会更复杂。Agent 会多次调用工具、多次和模型交互中间的每一条工具返回结果都可能被塞进下一次模型请求。比如一个 Agent 先搜索、再读网页、再写总结它可能内部已经发生了 5 到 10 次模型调用。这时候沿用“会话隔离”还不够你需要跟踪每一步的 tool_call_id 和上下文摘要。我的建议是每一步工具返回只保留结构化摘要而不是把网页全文或日志全量带回模型同时为 Agent 调用设置总 token 预算上限超过预算就终止任务并返回部分结果。6.2 把 token 消耗当成性能指标持续观测做完优化后我再也不把 token 消耗只当账务数据看而是把它当成和响应时间、成功率同等重要的性能指标。每次发布新功能都对比同样的回归用例集如果 token 消耗明显上升就说明 prompt 设计或上下文管理出现了退化。我还做了一个简单报表把每日请求次数、输入 token 总量、输出 token 总量、缓存命中率、重试率五条曲线放在一起。正常情况下它们应该维持平稳一旦某条曲线出现毛刺就意味着有异常用例或异常流程进入到了生产链路。最后分享一个我现在养成的小习惯每次写完验收脚本第一步不是跑真实数据而是拿 3 到 5 条最小样例跑一遍看 token 消耗是否和预估一致。如果这一步都不对就别急着上全量。宁可先在“小数据”上把流程磨干净也不要在“大数据”上烧完钱再复盘。那次 92 次请求烧掉 1340 万 token 的教训说到底就一句话让 AI 慢的从来不是模型而是我们把各种历史包袱全塞给了它。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进