上个月收到账单的时候,我盯着数字看了十秒钟,差点以为统计口径出了问题——一个内部Agent服务光Token费用就比上周期涨了40%。我没有换更便宜的模型,也没有砍功能,而是把整个Harness工作流重新排了一遍,两周后Token消耗硬生生降了51%。
这篇文章就把过程里拆过的问题、改过的配置、踩过的坑完整梳理一遍。如果你正在做Agent类应用,或者用Coze、Dify、n8n这类工作流平台跑自动化任务,却总觉得Token成本不可控,那这篇应该能给你一套可落地的思路。
1. 成本失控现场:Token都烧到哪里去了
1.1 一次看似简单的请求,为什么烧掉几万Token
先还原一个典型场景。用户问了一句“帮我查一下上周的订单数据,和上上周对比,再分析异常原因”。这句话本身只有几十个Token,但智能体实际跑下来可能消耗了2万到5万Token。
问题出在底层流程。Harness工作流为了执行这个任务,通常会这样跑:
- 第一轮:把系统提示词、全部工具定义、用户问题一起发给模型,模型选择先查订单列表。
- 第二轮:携带第一轮的完整对话、工具返回的订单列表(可能几百条),让模型继续判断下一步。
- 第三轮:再携带前面的全部内容,调用统计接口,接口返回大量明细。
- 第四轮:再次携带全部历史,让模型汇总对比。
- 可能还有第五轮:做异常归因时又翻了一遍订单数据。
每轮都在重复发送几乎相同的系统提示词、工具定义、历史消息。工具定义一次可能有3000到8000 Token,历史消息经过多轮累积后可能轻松超过1万Token。换句话说,用户只看到一次对话,底层已经因为多次重复带上“行李”而消耗了五倍以上的输入Token。
更隐蔽的是工具返回。很多内部API返回的是给前端用的完整字段,比如订单接口会把支付信息、物流信息、备注、甚至退换货标记全带回来。模型不关心这些,但这些Token照样按输入计费。
1.2 成本不是看单次价格,而是看“倍率”
很多人算Token成本只看每千Token的单价,忽略了两个更重要的事实。
第一,输入Token和输出Token价格不一样。主流模型的输出价格通常是输入的3到5倍。所以一次让模型“自由发挥”的长篇输出,可能比十次短输入还贵。
第二,Agent任务会放大Token消耗。一次任务往往不是“一轮”而是多轮,多轮之间的历史消息每一轮都会重新计费。最终账单基本等于“单次请求消耗Token乘以请求次数”,然后还要乘以工具调用轮数。
我自己习惯用一个粗略公式估算单次任务的成本:
单次Agent任务成本 ≈ 输入Token总量 × 输入单价 + 输出Token总量 × 输出单价
其中输入Token总量 ≈ (系统提示词 + 工具定义 + 历史消息 + 新输入) × 往返轮数
从这个公式就能看出来,降低成本的抓手不是去跟模型厂商砍价,而是压缩三个变量:往返轮数、单轮携带的固定内容、以及输出Token的浪费。
1.3 Harness工作流到底在管什么
这里说清楚“Harness”到底指什么。
在AI Agent领域,Harness可以理解为模型的“控制壳”或“运行框架”。它负责把大模型调用、工具选择、上下文组装、记忆读写、重试策略串成一条循环。你可以把它想象成一个操作系统:模型是CPU,Harness是调度器,工作流是任务清单。
有些开源项目直接叫DeepSeek Harness,它实现的就是一种Agent运行时的抽象。它让你不需要每次都手写“while循环里调模型、解析输出、执行工具、再把结果喂回去”的样板代码,而是通过声明式工作流把这些步骤串起来。
Harness工作流里每一环都会直接影响Token消耗:
- 上下文怎么组装,决定单轮携带多少Token。
- 工具怎么定义、返回什么,决定工具调用轮次和结果大小。
- 记忆怎么管理,决定多轮对话是不是无限膨胀。
- 路由怎么设计,决定是不是每件事都用最强最贵的模型。
所以Harness工作流不是一个神秘的东西,它就是你需要审计和改进的“成本中心”。搞清楚这一点,后面的优化才有方向。
2. 降本之前,先把账算清楚
2.1 没有度量就没有优化
任何优化都应该从测量开始。我见过很多团队凭感觉说“感觉上下文有点大”,然后盲调,结果越调越乱。
正确做法是在Harness里给每一次模型请求埋点,记录以下字段:
- 请求ID、任务ID、会话ID
- 模型名称
- 请求时间
- prompt字符数、Token数
- 补全Token数
- 工具调用轮次
- 是否命中缓存
- 路由策略命中的级别
如果你的Harness是自己写的,那埋点很容易。在调用大模型API那一步统一包一层,打印或上报即可。如果用的是开源Harness,通常也支持回调或中间件,可以拦截每次请求。
一个简单的记录结构示例:
# 记录一次模型调用的Token用量 def record_usage(request_id, model, input_tokens, output_tokens, cached_tokens): log_entry = { "request_id": request_id, "model": model, "input_tokens": input_tokens, "output_tokens": output_tokens, "cached_tokens": cached_tokens, "total_tokens": input_tokens + output_tokens, "timestamp": datetime.now().isoformat(), } usage_logs.append(log_entry)采集完日志之后,按“任务”维度聚合,统计每个任务跑了多少轮、平均每轮输入多少Token、哪个工具产生的历史消息最长。这一步做完,你会非常清楚地看到钱烧在哪儿。
2.2 一张表找出浪费大头
我习惯把浪费归成六类,每类都有对应的优化方向。下面的表格可以直接拿来对照排查:
| 浪费类型 | 典型表现 | 占比参考 | 修复方向 |
|---|---|---|---|
| 固定提示词膨胀 | 系统提示词写了5000 Token,且每个请求全量带入 | 15% - 25% | 精简提示词、按需动态拼接 |
| 工具定义冗余 | 全部工具定义每次全量发送,几十个工具加起来近万Token | 20% - 30% | 按意图动态注入工具定义 |
| 工具返回过大 | API返回全量字段或全部分页数据,模型根本用不上 | 20% - 30% | 字段裁剪、分页控制、结果摘要 |
| 历史消息无限制累积 | 多轮对话把一个月前的聊天记录全带上 | 10% - 20% | 滑动窗口、历史摘要 |
| 无效往返 | 模型反复调整参数调用同一工具,或工具调用失败后整段重试 | 10% - 15% | 结构化输出、失败重试降级 |
| 重复相似查询 | 用户反复问同一类问题,每次都重新计算 | 5% - 15% | 语义缓存、结果缓存 |
我这次优化的项目里,工具定义冗余和工具返回过大两项加起来占了差不多一半成本。这意味着只要把这两块控制住,整体成本就能明显下降。
2.3 50%目标怎么拆
降本50%不能靠一个大招,而是要靠多个小优化叠加。我当时把目标拆成四块:
- 语义缓存减少重复任务带来的Token消耗,目标贡献10到15个百分点。
- 上下文压缩与历史摘要,目标贡献10个百分点。
- 模型路由让简单任务走小模型,目标贡献10到15个百分点。
- 工具定义动态注入与结果瘦身,目标贡献15到20个百分点。
每一项都对应一类具体改造,而且每一项都可以独立上线、独立验证。这种拆法有个好处:即使某一项没达到预期,整体收益依然可能逼近50%,风险被分散了。
3. Harness工作流成本优化的五个核心抓手
3.1 语义缓存:让重复查询不再烧钱
在一个内部工具里,用户问“最近一周的销售额是多少”和“上周到现在卖了多少”,本质上是同一个问题。如果没有缓存,Harness会原封不动地把这个问题送给大模型,重新跑一遍工具调用和推理。
语义缓存的思路是:在进入Agent循环之前,先把用户输入做向量化,然后在历史缓存里找相似度最高的记录。如果相似度超过阈值且缓存结果没过期,直接返回缓存答案,完全不用调用大模型。
实现上,可以给Harness加一层前置拦截器:
def cached_agent_run(query, threshold=0.92, ttl_seconds=3600): query_vec = embed(query) similar = cache_store.search(query_vec, top_k=1) if similar and similar.score >= threshold and not expired(similar.ts, ttl_seconds): return similar.answer, {"cache": "hit"} answer = agent.run(query) cache_store.save(query_vec, query, answer) return answer, {"cache": "miss"}这里有个细节要提醒:阈值别设太低,否则容易误命中。比如“本月退货率”和“本月退款率”听起来相关,但答案是两回事。我实际用下来0.90到0.93是一个比较合适的区间,具体要看你的场景和Embedding模型。
为了控制新鲜度,还需要给缓存设置TTL。订单、报表这类数据往往需要分钟级刷新,而FAQ类内容可以缓存几小时甚至一天。Harness里可以给不同类型任务打一个cache_policy标记,让缓存规则可配置。
3.2 上下文压缩与记忆分级
多轮Agent最容易出现的问题就是“把所有历史都带上”。对话到第十轮时,前九轮的原文一共可能有几万Token,其中大部分是不重要的细节。
我的做法是分级记忆:
- 最近两轮消息保留原文。
- 更早的消息由摘要机制定期压缩成一段结构化摘要。
- 关键事实(用户ID、日期范围、筛选条件)单独抽出来放进一个固定长度的“事实槽”。
给Harness写一个简单的历史管理函数:
def compress_messages(messages, max_recent=2): recent = messages[-max_recent:] old = messages[:-max_recent] summary = summarize(old) # 调用一次模型生成摘要,可外部控制 facts = extract_facts(old) return build_system_context(summary, facts) + recent注意,摘要本身也是一次模型调用,会产生Token成本。所以不要让每一轮都做摘要。更合理的方式是设定一个触发条件,比如历史消息总长超过6000 Token时,才触发压缩。
也别把摘要做得太激进。如果摘要丢掉了关键实体,比如订单号、客户名,后面工作流就没法正确查数据。所以压缩时我一般把事实列表单独保留,摘要只负责描述过程性的内容。
3.3 模型路由:大模型把关,小模型干活
不是所有任务都需要满血版最强模型。很多Agent任务里,意图判断、分类、简单格式化这类工作完全可以用小一号的模型完成。
我在Harness里加了一条路由规则:
def route_task(task): if task.type == "classification": return "fast-model" # 便宜小模型 if task.need_tools and task.complexity > 0.7: return "strong-model" # 强模型 if task.type == "extract" and len(task.text) < 500: return "middle-model" # 中档模型 return "default-model"比如用户要“把这段文本里的日期、金额、订单号提取成JSON”,这个任务根本不需要多强的推理能力,用一个便宜的小模型就能完成,一次调用花费可能只有强模型的十分之一。
更极端的做法是“级联路由”:先让小模型给任务打一个置信度分数,只有置信度低的任务才升级到强模型。这种方式可以再压一部分成本,代价是实现复杂一些,需要维护置信度阈值。
需要注意的是模型路由不能只看价格。如果小模型频繁犯错导致重试,重试成本可能抵消节省。所以我对路由策略的要求是“宁可把模棱两可的任务升级,也不要为了省钱牺牲准确率”。
3.4 工具结果瘦身:别让模型看不需要的字段
很多时候工具API是给前端用的,返回了一堆模型不需要的字段。Harness在调用工具之后、把结果塞进上下文之前,应该做一个瘦身步骤。
举一个实际例子:查订单接口原来返回:
{ "order_id": "A123", "user_name": "张三", "amount": 199.0, "items": [{"sku": "x", "price": 99.0}, {"sku": "y", "price": 100.0}], "shipping_address": "...", "payment_receipt": "...", "remark": "...", "status_history": [...] }但Agent当前只关心订单金额和SKU列表。那就应该只留下这两个字段:
{ "order_id": "A123", "amount": 199.0, "item_count": 2 }这个操作要在Harness的工具调用代理层完成:定义每个工具的“返回字段映射”。如果工具本身不支持字段选择,就由代理层接收完整响应后做裁剪,再决定要不要把结果传给模型。
另外,对于可能返回大量列表数据的情况,必须处理分页。不要让工具一次性返回1000行订单明细。可以改成先返回“总数+前50条摘要”,让模型决定是否需要翻页。很多场景里模型只需要汇总值,根本不需要明细,直接让工具端做聚合返回一个总数就够了。
3.5 并行调用与步骤合并:减少往返轮次
每多一轮模型调用,就会多带一遍上下文,成本是乘法级别的。所以降低往返轮次是降本的一个关键点。
传统Harness工作流可能是串行的:
调用订单列表 -> 等待 -> 模型决定 -> 调用统计接口 -> 等待 -> 模型总结这其实可以改成:
同一轮并行调用订单列表和统计接口 -> 模型拿到两个结果直接总结很多模型已经支持在单次回复里发起多个工具调用。Harness完全可以用一个循环把相互独立的调用合并在同一轮执行。这样原先需要三到四轮的流程可能压缩到两轮。
我改造时在Harness里加了并行工具调用的支持:先解析模型本轮想调用的工具列表,执行依赖分析,没有依赖关系的工具同时执行,然后合并结果再发给模型。
这个优化对Token成本的影响很大。一轮并行调用相比三轮串行调用,至少省掉了中间两轮携带全部上下文的开销。
4. 实操复现:一个Harness工作流的改造全过程
4.1 改造前的流程设计
为了便于理解,我把一个简化版的Harness工作流配置拿出来展示。
改造前的配置大致长这样,代表了一种“简单但费钱”的写法:
agent: name: data_analyst model: strong-model system_prompt: | 你是一个数据分析助手。你可以访问订单系统、用户系统、商品系统。 请根据用户问题,主动选择工具并完成分析。 注意要给出详细的分析过程和结论。 tools: - order_search_tool - user_search_tool - product_search_tool - report_tool - notification_tool memory: mode: full_history max_rounds: 8这个配置的问题很明显:模型选择单一、工具定义全量注入、记忆模式是全量历史、最大轮次给到8。每个请求都会把四个工具的完整定义加载进去,不管用户是否涉及用户系统。
实际跑一次的时候,日志显示平均每轮输入Token约1.8万,平均往返轮次约4.2次,单任务总输入Token接近7.6万。如果再算上输出Token,单任务总Token基本在9万左右。
4.2 改造后的流程设计
改造之后,我把Harness配置重构为更分层的方式:
agent: name: data_analyst_v2 routing: router_model: fast-model complexity_rule: threshold: 0.7 high_model: strong-model low_model: middle-model system_prompt_loader: mode: dynamic sections: - general_rules - current_time - task_specific_hint tool_registry: enable_by_intent: true intent_mapping: order: [order_search_tool] customer: [user_search_tool] report: [report_tool] default: [order_search_tool, report_tool] memory: mode: hierarchical recent_window: 2 summary_trigger_tokens: 6000 facts_slot_size: 500 cache: semantic: enabled: true embedding_model: embedding-model threshold: 0.92 ttl: report: 300 faq: 86400 tool_output: prune: enabled: true field_map: order_search_tool: keep_fields: [order_id, amount, item_count, status] report_tool: keep_fields: [summary, total, trend] max_rounds: 4 parallel_tool_calls: true对应到代码层面,核心循环也变干净了:
def optimized_agent_run(query, cache): # 第一层:语义缓存 if cache.hit(query): return cache.get(query) # 第二层:意图路由与工具动态注入 intent = classify(query) # 用小模型 tool_specs = load_tools_by_intent(intent) # 第三层:上下文组装 system = build_system_prompt(intent=intent, date=today()) history = hierarchical_memory(query) # 第四层:并行工具调用 plan = strong_model.decide(system, history, tool_specs) if plan.tool_calls: results = parallel_execute(plan.tool_calls) bounded = prune_results(results) final_answer = strong_model.finish(plan, bounded) else: final_answer = plan.answer cache.save(query, final_answer) return final_answer这套流程的核心变化是:
- 不再一股脑把所有工具定义塞给模型,而是先做意图识别,动态加载相关工具。
- 固定提示词按任务拼装,不再每次全量带入。
- 记忆改为分层,超过阈值就触发摘要。
- 工具调用支持并行,且输出字段经过裁剪。
- 前置语义缓存,重复任务直接返回。
4.3 数字对比和收益量化
改造后我做了A/B测试,用同样的100条真实历史请求跑了两套配置。为了避免干扰,测试期间没有改模型本身。
| 指标 | 改造前 | 改造后 | 降幅 |
|---|---|---|---|
| 平均单任务输入Token | 76000 | 28000 | 63% |
| 平均单任务输出Token | 14000 | 7000 | 50% |
| 平均总Token | 90000 | 35000 | 61% |
| 平均往返轮次 | 4.2 | 2.1 | 50% |
| 语义缓存命中率 | 0% | 18% | - |
| 单任务估算成本(按统一模型单价) | 0.90元 | 0.42元 | 53% |
虽然不是每一项都刚好砍半,但最终汇总成本下降了53%,超过了50%的目标。其中工具定义动态注入和提示词精简贡献最大,缓存命中率在前置的语义层刚上线时还不高,因为历史数据少,但运行一周后命中率稳步上升到25%左右。
我还观察到,优化后单次任务的响应耗时长了一些,因为并行工具调用虽然减少了往返次数,但同步等待多个工具返回需要时间。不过整体用户体感变化不大,因为模型输出的轮次少了。
5. 常见问题与排查技巧实录
5.1 token失效与403报错怎么查
实际运行中,Harness工作流经常要跟外部系统做OAuth认证,Token失效是一个高频问题。
最常见的报错是类似“sign-in could not be completed token exchange failed”或“token endpoint returned status 403 forbidden”。这类报错背后的通用原因可能包括:
- 授权码或刷新令牌过期:用户长时间未使用,刷新令牌被吊销。
- 令牌作用域不足:Harness请求头里带的scope和实际接口要求的不匹配,服务端返回403。
- 服务器时钟偏差:JWT校验时如果服务器时钟偏差超过容限,会直接拒绝。
- 刷新令牌已被消费:有的授权服务器规定刷新令牌只能使用一次,重复使用会失效。
排查步骤我从踩坑里总结出四步:
- 先看日志里的完整错误码和响应体,不要只看“403”就完事。
- 检查刷新请求里是否带了正确的client_id、client_secret、grant_type。
- 对比认证服务器返回的expires_in和本地时间,确认是否存在时钟偏差。
- 查看是不是存在并发场景下两个请求同时用同一个刷新令牌,导致后一个被吊销。
特别提醒一件事:不要把刷新令牌存在多实例共享的无持久化内存里。一旦其中一个实例刷新成功、另一个实例还用旧令牌发起刷新,必然失败。最好用Redis或数据库持久化,并加锁保证同时只有一个实例执行刷新。
5.2 上下文超长与摘要丢失
优化后经常遇到的一个新问题是“摘要把关键信息弄丢了”。
比如原始对话里用户说过“不要统计退款订单”,摘要只写了“用户要求统计订单”,模型在后面真正跑统计时就把退款订单也算进去了。这种问题非常隐蔽,因为流程能跑通,但结果错了。
我的解决办法是:
- 事实槽单独抽取关键约束,比如“排除退款订单”“只看北京地区”“时间范围是上周一到上周日”。这些约束不放进摘要,而是作为独立结构体注入系统提示词。
- 摘要只负责归纳“来龙去脉”,不负责保存数字、人名、日期等精确事实。
- 压缩触发前先做一次事实校验:把新增的事实列表和上轮已有事实合并,出现冲突时以最新为准。
另外,上下文超长报错也不能完全靠压缩规避。Harness里应该在把消息发给模型之前做一次Token预检,估算消息长度,超过模型最大上下文窗口的80%就强制触发压缩。
5.3 缓存命中率低于预期
语义缓存上线初期命中率可能只有个位数,先别急着怀疑缓存没用。需要检查三点:
- 相似度阈值是不是太高了。0.95以上的阈值基本等于要求一模一样,建议从0.90开始往下调。
- Embedding模型是否一致。有的查询用模型A编码,缓存也是模型A编码,但后来换了模型B,两个向量空间不同,相似度计算完全失真。
- 用户表达是否高度口语化。同样是“查订单量”,用户可能说“订单多不多”“最近卖了几单”“给我看看销量”。这种多样性会让单纯向量相似度很难覆盖。
应对方式有两种:一是用LLM做查询改写,把用户问题归一化成标准问法再走缓存;二是在缓存层做“同义词扩展”,例如把“销量”“订单量”“销售额”相关词映射到同一个模板。第二种更便宜。
5.4 用Coze、Dify、n8n时同样要注意Token
很多读者可能用的是现成的工作流平台,而不是自研Harness。这些平台的底层逻辑一样,只是把Harness的概念变成了“节点”“编排”和“流程”。
在Coze里,每个节点之间的文本传递都会消耗Token。如果一个节点的输出是一个长JSON,下一个节点全量引用,那Token一样会爆炸。我的建议是:在节点之间加一个“数据清洗”步骤,只保留下一个节点需要的字段。
在Dify里,如果开启Chatflow,历史消息默认会带上。需要在记忆组件里设置“最大消息数”,并开启摘要。上下文超长报错(比如标注为“上下文超长”)大多是记忆组件配置问题。
在n8n里,没有内置的Token计量,但可以通过HTTP Request节点调用大模型时手动记录响应里的usage字段,再把日志汇到表格里做分析。
无论哪个平台,优化原则都和自研Harness一样:减少每条消息的体积、减少调用轮次、缓存重复结果。
6. 一点心得体会
这次改造给我最大的一个感受是:Token成本优化不是上线一个缓存就完事的,而是一个系统性的工程。把成本拆到“固定开销”和“可变开销”两个维度去理解,会清晰很多。
固定开销是系统提示词、工具定义、摘要这些每轮都要带的上下文;可变开销是工具返回、历史累积、无效往返这些随任务不同而波动的部分。50%的降本目标,本质上是把固定开销压缩到原来的三分之一,同时把可变开销里的重复部分用缓存和并行调用抹掉。
还有一个小技巧值得一提:不要把优化后的配置一次性全量上线。先开一部分,比如只做工具结果瘦身,观察一个周期;再加动态工具注入,再观察。每一层收益单独记录,出问题的时候回滚也容易。
最终这轮改造完成之后,我又顺手把日志面板加了一个Token成本趋势图,每周自动汇总。现在每次看到成本波动,第一反应不再是焦虑账单,而是去翻日志找波动来源。这个习惯比任何单次优化都重要。
如果你也在做一个Agent服务或工作流,建议先从记录Token用量开始。花一个下午把每一次模型调用的Token数打出来,按任务聚合,你大概率会发现成本大头和自己想象的不太一样。找准大头再动手,50%并没有想象中那么难。