九个月,20万行代码,每个月烧掉40亿+token,最后换来的不是一个大厂级的团队产品,而是一套我自己称它为 Harness 的架构。这名字不是噱头,也不是为了押韵,而是因为它实实在在解决了我在做AI原生应用时最大的痛点:怎么让大模型在一个可控、可观测、可预算的框架里,稳定跑完真实业务流程。
这套东西不是传统意义上的“套壳应用”,而是一个Agent运行时。它管着模型的调用时机和频次,管着工具怎么被解释、怎么被调用,管着上下文从哪里来、到哪里去,还管着每一次token消耗的流向。如果你也在做Agent类应用,或者正在被“模型什么都懂但落地全是坑”折磨,这篇文章值得你看完。我会把从0到1的过程中踩过的坑、算过的账、重构过的代码,以及和大模型API打交道时最常遇到的token问题,一起梳理清楚。
1. 为什么是 Harness 架构
1.1 Harness 架构到底是什么
“Harness”这个词,字面意思是“缰绳、安全带、夹具”。放在传统软件开发里,它常被翻译成“测试夹具”或“执行装置”。但在AI Agent领域,我更愿意把它理解成“套在模型外面的那层控制装置”。模型本身只是一个会预测下一个token的引擎,它并不知道你的业务规则、成本红线、权限边界和失败恢复策略。你需要一个东西在模型前面接住用户的请求、编排任务步骤、调用各种工具、把结果再喂回去,不断循环,直到任务完成。这个循环和配套机制,就是Agent Harness。
我最早跟朋友解释的时候打了个比方:模型像一台马力很强但容易失控的发动机,Harness就是底盘、方向盘、刹车和仪表盘。你不能指望发动机自己会刹车,更不能指望它自己知道油箱还剩多少油。你要在它外面造一整辆车。大模型API返回的文本,只有在被解析成结构化动作、绑定到真实工具调用、再经过权限校验时,才真正变成能力。这些步骤全部发生在Harness内。
实际设计的时候,我的Harness有五个职责:请求进入时做目标拆解和任务规划;执行时做工具路由和参数注入;过程中维护完整的上下文窗口和记忆;前后端之间用事件机制传递状态;每个步骤都对token消耗做计量和配额检查。任何一个环节缺失,应用在真实流量下都会出问题。比如没有token计量,一个失控循环可能在几分钟内烧掉几个月预算;没有状态持久化,进程一重启所有长任务全部丢失。
1.2 为什么不直接用现成的编排框架
你可能会问:市面上已经有各种Agent编排框架,为什么还要自己写一套Harness?这个问题我每次复盘都会被问。说实话,我不是为了“造轮子”而造轮子,而是因为现成的框架在单人开发场景下反而更贵。
第一个原因是抽象层级太多。很多框架为了兼容各种模型和工具,做了大量封装。平时跑demo很爽,一旦需要在某些场景里精确控制“这一步只调一次模型”“那个工具返回值要缓存”“某些失败不能自动重试”,你得绕开好几层抽象才能碰到底层逻辑。单人维护时,这种认知负担很容易让人崩溃。
第二个原因是黑盒调试太难受。我要把token消耗算到每个子任务头上,需要看到每一次模型调用的完整日志,包括输入、输出、token明细和耗时。大部分现成框架会把日志格式化得“好看了”,却丢掉了原始信息。我更信任自己写的日志,因为出了问题能在代码里直接定位。
第三个原因是模型服务迭代太快。我初期的Harness换过三次底座设计,每次都是因为某个模型厂商推出了新的推理特性或新的计费规则。自己写架构,改起来成本最低。我不是说现成框架不好,而是它适合团队、适合产品方向稳定的场景。对一个人想快速验证核心逻辑的项目来说,轻量自研反而更省时间。我的原则很简单:只做核心闭环,其他一切从简。
1.3 Harness 的层级设计
整个应用在逻辑上分成五层,每层边界我都尽可能画清楚,因为边界模糊是代码腐化的开始。
| 层级 | 核心职责 | 关键设计 |
|---|---|---|
| 接入层 | 处理用户请求、会话管理、鉴权 | 用短时凭证控制入口,避免令牌外泄 |
| 编排层 | 目标拆解、任务规划、循环控制 | 内置预算检查,超过阈值强制收敛 |
| 能力层 | 工具注册、参数解析、调用转发 | 所有工具通过统一schema声明,模型只能看到白名单 |
| 记忆层 | 会话摘要、向量检索、上下文拼接 | 滚动窗口+定期摘要,控制prompt长度 |
| 计量层 | token统计、成本核算、日志追踪 | 每次模型调用都记录输入输出token数 |
其中编排层是Harness的核心骨架,也是整个项目里最早稳定下来的部分。其他层都改过至少一轮,只有编排层的“循环—收敛—恢复”机制从第三个月后就没伤筋动骨动过。原因是我把“永远不要让任务无界循环”写成了铁律,所有循环都必须有明确的结束条件或者预算上限。
2. 20万行代码是怎么长出来的
2.1 模块划分与代码构成
我统计过仓库里的所有代码,最终20万行大概是这么分布的:核心harness运行时约8万行,包括编排循环、状态机、上下文管理、事件总线;模型网关约2.5万行,负责多模型接入、限流、重试和统一响应格式;工具协议与连接器约3万行,覆盖了二十多类外部工具;持久化和任务快照约2万行;任务调度与并发控制1.5万行;前端工作台约2.5万行;剩余是测试、脚本、配置和文档。
20万行听起来很多,但放到九个月的时间跨度里,每天平均下来只有700多行“进账”。而且这个数字并不全是“从指尖敲出来的业务逻辑”,其中有很大一部分是工具定义、参数校验规则、测试用例和配置模板。我始终觉得,在AI应用里,代码的重量不在“数量”,而在“关系”——模型输出、工具输入、状态迁移这三者之间能不能严谨地咬合。
写工具连接器的时候是最枯燥的。每个工具都要做输入输出校验、错误码映射、超时处理和幂等标识。模型生成的参数再漂亮,到了真实系统里也得按协议来。最典型的是数据库工具:模型可能生成一个只差一个逗号就非法的SQL,如果没有前置校验,后果就是线上事故。所以我在每个工具前面都加了一层参数解析器和校验器,这部分代码非常机械,但绝对不能省。
2.2 代码量的合理性与瘦身体验
你可能担心20万行里面有很多“为了代码而代码”的水分。我承认,确实有一部分是为了配合测试框架硬写出来的。但核心运行时的代码是实打实经过重构的。
第一个版本只有4万行,把状态全部放在内存里,跑单个demo任务一点问题没有。但一旦多任务并发加上进程重启,所有运行状态瞬间归零。后来我把状态改为落库加事件溯源,代码量蹭蹭涨了2万多行。代价是每次状态变化都要追加一条事件记录,代码里多了很多序列化和反序列化逻辑。但收益也很直接:任何人都能通过时间线重放整个任务过程,排查问题不再靠猜。
另一个大幅增加代码量的地方是“给Harness做约束”的各种检查器。比如循环检测器,专门识别模型是否在重复执行同一个工具拿同一个结果;再比如上下文压缩器,会把超长对话里的旧内容自动替换成摘要。这些辅助逻辑加起来超过了3万行。它们不会提升“智能感”,却是让应用在真实环境中活下来的关键。
我更愿意把这20万行理解成一个不断做减法的结果。每次重构都会删掉一批临时补丁,再把更通用的逻辑抽象出来。比如工具注册模块,最初每个工具都是硬编码调用,后来改成用装饰器声明,代码行数反而下降了,但可支持的工具数量翻了几倍。
2.3 重构触发点与踩坑记录
九个月里有三次大重构,每一次都是被线上问题逼出来的。
第一次是第二个月,当时一个长任务跑着跑着内存爆了。原因是没有限制上下文长度,模型每轮思考都把完整历史塞进去,任务越长消耗越恐怖。那次让我明白了Harness必须管住上下文,于是引入了滚动窗口加摘要机制。
第二次是第四个月,任务在工具调用失败后反复重试,最高纪录是一个任务内同一个API调用失败了14次,烧了十几万token。我随后加入了“单点失败次数上限”和“失败类型感知”:网络错误可以重试,但业务逻辑错误直接终止,绝不死磕。
第三次是第七个月,我把同步执行改成异步任务队列。之前是一个请求独占一个进程线程,长任务会拖垮整个服务。改造后,Harness可以在任意时刻快照当前任务,暂停、恢复、迁移到另一台机器。代码量因此又涨了一波,但系统的可运维性提升了不止一个档次。
如果你也要写类似架构,我的建议是:不要在架构还不清晰的时候就铺代码,先把一个最简闭环跑通,再根据真实故障来重构。没有线上压力的代码优化,很容易变成自嗨式的过度设计。
3. Token 消耗与成本控制:一个月40亿+ token怎么烧的
3.1 40亿token消耗在哪里
很多朋友听到“40亿+token”第一反应是“你疯了吧”。其实如果拆开算,这个数字很正常。它不只是生产流量,还包括我开发调试、跑测试、生成工具调用参数、失败重试时的消耗。
我建了一个计量表,把所有模型调用按用途打标。核心推理占大头,每个任务平均要经历三到五次规划与反思循环,每次调用约1.8万token;上下文压缩和摘要也很费,模型要把超长历史重新总结,每次约6000token;工具调用解析和参数生成虽然单次token不多,但调用频次极高,加起来也是几亿的量级。
| 用途 | 单次平均token | 月调用次数 | 月消耗token | 占比 |
|---|---|---|---|---|
| 核心推理与任务规划 | 18000 | 65万 | 11.7亿 | 29% |
| 上下文压缩与历史摘要 | 6000 | 130万 | 7.8亿 | 20% |
| 工具调用与参数生成 | 4000 | 180万 | 7.2亿 | 18% |
| 模型路由与意图识别 | 1000 | 420万 | 4.2亿 | 10% |
| 失败重试与补充追问 | 20000 | 8万 | 1.6亿 | 4% |
| 开发调试、测试和实验 | 混合 | 大量 | 7.5亿+ | 19% |
这里每一行背后都是真实的业务场景。核心推理是主线,但真正让我肉疼的是“上下文压缩”。模型记不住太长的交互历史,最原始的办法是每次都把全部消息塞进去,token直接爆炸。压缩器虽然能控制成本,但它本身也在烧token。后来我在记忆层加了向量检索,只把和当前任务相关的历史片段拼回去,整体消耗才明显降下来。
3.2 控制token成本的三板斧
第一板斧是缓存。很多用户请求的意图和结果是高度相似的。我把工具调用的结果缓存起来,做了一层语义缓存,相同问题在窗口时间内直接命中缓存,不再调用模型。这一下省掉了将近25%的token。缓存不是只保存最终回答,连中间步骤的规划结果也缓存,避免同一个任务反复规划。但要注意缓存失效策略,业务数据更新之后必须立刻清缓存,否则模型会给出一本正经的错误答案。
第二板斧是上下文治理。给每个会话设定token上限,超了就触发压缩和归档。模型只保留最近的详细对话以及更早部分的摘要。同时,工具返回的长文本不再全量塞给模型,而是先截断、提取关键字段,再让模型决定是否需要查看全文。这个策略特别好用,把“无效token”砍掉了至少三分之一。
第三板斧是模型分级。不是所有任务都需要最聪明的模型。意图识别、实体提取、简单改写这些用轻量模型就能做;只有复杂推理和方案生成才动用顶级模型。Harness的路由层会自动根据任务难度分配模型。为了不让路由本身消耗太多token,我采用了一套基于规则的初筛再加一次小模型确认的方案,而不是每次都用大模型来判断该用哪个模型,否则就成了“为了省钱反而更贵”。
3.3 Token用量监测与熔断机制
没有监控的成本控制都是空话。我单独做了一个token仪表盘,能够实时看到今天的消耗、环比变化和各模块占比。我给自己设了三个阈值:当天消耗达到预算的60%时预警,80%时限制非核心任务,100%时自动熔断。熔断之后Harness会停止一切非必要调用,只保留最基本的查询能力。
有次某条链路因为模型输出格式突然改变,导致解析失败后不断循环重试,一个晚上烧掉了平时三天的用量。就是靠仪表盘和第二天的告警邮件发现的。事后我加了一条硬规则:任何工具调用失败后,第二次重试前必须经过“是否值得重试”的判断,而判断本身使用缓存结果,不调用模型。熔断机制是我认为所有Agent应用都必须具备的东西,因为模型的不可控性决定了你永远无法预测一次失误能烧多少钱。
4. 一个人开发九个月:流程、心态与效率
4.1 时间线复盘:每个月都在做什么
九个月的时间,我大致分成四个阶段。第一个月和第二个月是“验证期”,我甚至没写Harness,只是手写脚本调各种模型API,测试它们对工具调用的理解能力。跑了上百个小实验,才确定要做的核心机制是什么。这个阶段没写多少代码,但为后面的架构攒了最重要的认知。
第三到第五个月是“骨架期”,我搭建了第一版Harness,能跑通最简单的单任务闭环。这阶段代码量涨得最快,但也是问题最多的时候。第六到第八个月是“打磨期”,我开始把真实工具接进来,比如数据库、搜索、文件处理、第三方系统API。每一天都在跟各种奇奇怪怪的错误打交道,比如返回字段类型不一致、权限边界不清晰、异步回调丢失。第九个月基本在做减负:删掉冗余模块,补测试,写文档,把最脆弱的几个环节加固。
单人的好处是决策链路短,你今天想改一个架构设计,晚上就能落地。坏处是没人帮你review代码,所有bug都靠线上反馈和自我审视。我给自己定的规矩是:每一段核心代码写完必须冷置一晚,第二天再回来看,往往能发现前一天没想清楚的地方。
4.2 每天的工作流与工具链
我的一天大概是这样的:早上先看token仪表盘和错误日志,确认晚上没有触发熔断;上午写Harness核心调度逻辑,注意力最集中的时候处理最难的问题;下午写工具连接器和测试用例,这段时间适合做重复度高的工作;晚上我会开着Harness跑一组长任务,让它在后台运行,第二天早上看结果和日志。
还有一个小习惯是“用自己造的Harness来帮自己写代码”。我会把Harness接上代码库索引,让它帮我做代码搜索和模板生成。虽然它不能直接写很复杂的功能,但能省去大量查找和整理的时间。一开始我也担心这样做会让代码质量下降,实际用下来发现,只要对模型生成的代码做严格review,效率确实提升不少。
开发过程中我也踩过“完美主义”的坑。最开始我想把所有现有模型供应商都适配一遍,做了大量无用功。后来我把模型接入层改成统一协议,新模型接入只需要写一个几十行的适配器,这才把自己从无穷无尽的适配工作中解放出来。单人开发一定要学会划分优先级,砍掉那些暂时不重要的需求,否则九个月大概率连一个能用的东西都憋不出来。
4.3 单人开发的极限与防御策略
我承认,20万行代码加上每月40亿token的成本,在纯商业视角下并不划算。但作为独立开发者,这个项目的价值在于跑通了一条从“模型API”到“完整应用”的技术路径。回头看我最大的防御策略就是用Harness把风险集中管理。
比如权限控制,所有外部工具调用都经过Harness的令牌代理,而不是让模型直接持有真实凭证。这能防止模型在不可预测的情况下调用危险操作。又比如预算控制,前面提到的熔断机制就是防御策略的兜底。我宁可业务暂时不可用,也不允许一个循环把整个项目的钱烧光。
一个人开发意味着系统必须足够简单到你能记住每一个细节。我后来的做法是:每新增一个功能,就要评估它对整个闭环复杂度的增加,如果增加的复杂度大于收益,就砍掉。这个判断标准帮我挡掉了至少十个看起来很酷但没必要做的功能。
5. 常见问题与排查技巧实录
5.1 token失效与自动续签的正确姿势
在开发过程中,遇到最多的问题就是“token失效”。这些错误信息五花八门,比如登录失败、token exchange failed、refresh_token无效等等。归结起来,绝大多数都和令牌生命周期管理不规范有关。
用JWT做身份验证时,access_token一般只有几十分钟有效期,refresh_token用来换新的access_token。最初我的代码是等access_token真正过期后才去刷新,结果在并发场景下经常出现一批请求同时拿着过期token去访问,瞬间全部失败,然后大家一起刷新refresh_token,又造成刷新接口被限流。后来我把刷新逻辑改成“预刷新”:在token过期前5分钟就主动换新的,并且用一个全局锁保证同一时刻只有一个刷新请求在跑。这样基本上消灭了大面积失效问题。
import threading, time class TokenManager: def __init__(self, access_ttl=3600, refresh_before=300): self._lock = threading.Lock() self._access_token = None self._expires_at = 0 self._refresh_before = refresh_before self._access_ttl = access_ttl def get_token(self): # 如果即将过期,就刷新 if self._expires_at - time.time() < self._refresh_before: self._refresh() return self._access_token def _refresh(self): # 只允许一个线程进入刷新流程 with self._lock: if self._expires_at - time.time() >= self._refresh_before: return # 这里调用认证服务换取新access_token和refresh_token new_token, new_refresh, expires_in = self._call_auth_server() self._access_token = new_token self._expires_at = time.time() + expires_in # 如果服务端轮换refresh_token,需要同步保存另一个坑是refresh_token轮换。有些认证服务在每次刷新后会返回新的refresh_token,旧的立即失效。如果你并发地刷新了两次,第一次返回的新refresh_token还没保存,第二次又把旧refresh_token传过去,就会得到“invalid refresh_token”错误。解决办法就是上面代码里的锁,必须保证刷新动作串行化,并且刷新成功后立即持久化新的refresh_token。
5.2 sign-in could not be completed / token exchange failed 排查
和第三方认证系统对接时,你会看到很多莫名其妙的“token exchange failed”错误。我整理过一份排查清单,按出现频率排序:
| 现象 | 常见原因 | 验证手段 |
|---|---|---|
| sign-in could not be completed | 回调地址和授权时不一致,或state参数缺失 | 对比授权请求与回调request的state值 |
| token endpoint returned 400 | refresh_token过期、格式错误或已被吊销 | 检查refresh_token是否被前一秒的并发请求消耗过 |
| error sending request | 客户端时间偏移、SSL证书校验失败、网络代理异常 | 同步系统时间,检查证书链,关闭代理测试 |
| 403 forbidden: country/region/territory not supported | 服务端对来源区域或IP做了限制 | 确认当前出口IP是否在服务允许列表内 |
| failed to refresh token: empty string | 代码里refresh_token字段传了空值 | 统一在配置中心读取,不在代码里写死 |
很多人忽略的是“系统时间偏差”。有一次我排查了很久,最终发现服务器时间慢了五分钟,导致JWT的“nbf(not before)”校验不通过。所有token都会在签发五分钟后才“生效”,那五分钟里的请求全部失败。这个问题说大不大,但极其隐蔽,建议把所有机器的NTP同步检查一遍。
5.3 在Harness循环里如何安全使用token
Harness和普通应用不太一样,因为它会在一次任务里进行多轮模型调用,每轮可能使用不同的外部服务。token的传递和使用更需要谨慎。
第一,日志里绝不能打印完整的token。我见过太多线上事故是因为日志平台权限管控不到位,导致token泄露。我的做法是在日志输出前统一脱敏,只显示前几位和后几位,中间用星号代替。这样既方便排查,又不会泄露完整凭证。
第二,Harness内部使用独立的内部令牌,不要把用户的原始token传给模型或者工具。所有外部调用都通过一个代理模块,实际凭证只存在于内存中,不进入prompt,也不进入工具参数。否则模型可能在下一次回答里“不经意”地把token复述出来。这听起来像段子,但我确实在早期实验里遇到过模型把环境变量里的密钥当上下文输出出来的情况。
第三,根据最小权限原则分配token作用域。不要给一个工具颁发能访问所有资源的令牌。Harness每次调用工具前,都会生成一个带临时权限的短时令牌,用完即焚。即使模型调用链出错,攻击者能拿到的也只是某一个具体操作的小权限,而不是整个系统的钥匙。
最后分享一个实用技巧
如果你也要做类似Harness架构的应用,我最后特别想分享的一点是:把“token消耗”当作系统资源来看待,而不是账单上的一行数字。没有计量就没有优化空间。我在代码里给每一次模型调用都加上了trace_id,从用户请求到最终响应的每一条链路都能回溯。任何一段逻辑如果消耗token超过预期,我都能定位到具体是循环失控、上下文过长还是工具调用太笨。
还有一个小技巧是,在开发环境中使用固定模型参数并关闭所有扩展能力,因为扩展能力往往会悄悄增加token消耗。比如某些平台默认开启的“思考过程”或“补充分析”,单次调用可能多烧几千token,你在开发时不一定感觉得到,但上线后一旦并发量上来,账单就会给你惊喜。
对我来说,这九个月最大的收获不是那20万行代码,也不是学会怎么省token,而是彻底理解了“把大模型包进一层可控制的壳”这件事有多重要。Harness听起来很技术,本质上却是在做一件很朴素的事:不让失控的东西失控,不让不可预测的东西影响业务底线。希望我的这些踩坑记录,能让你少走一点弯路。