如果你也在自养Agent,我建议你养成一个习惯:定期翻一遍账单,别只看大头支出,专门盯着那些几毛几分的小额扣费。上周我就翻出一笔0.0211元的扣费,第一反应是“不是都用的免费模型吗,哪里来的钱?”顺藤摸瓜查了一圈日志,最后发现了一个特别值得写下来的事实:免费模型在这一轮任务里明明答对了,是我自己写的判定代码把它判成了“失败”,然后系统一声不吭地启动重试链,让付费模型兜了一次底。任务最终是成功的,监控面板全绿,只有账单悄悄漏了底。
这个标题就是那起小事故的完整记录。这篇文章不会有那种“教你零成本搭建Agent”的漂亮话,我会把这笔0.0211元的来龙去脉、误判代码长什么样、日志里是怎么还原出整条真相的,全部摊开讲清楚。搞Agent开发的朋友,尤其是喜欢用免费/低成本模型当主力的人,这篇应该能帮你避开同一个坑。
1. Agent任务里的“判分环节”:质检逻辑是怎么工作的
1.1 我养的这个Agent每天在干什么
先交代一下项目背景。我维护的小Agent核心功能并不复杂:每天定时跑几轮任务,输入是一些非结构化文本,比如订房确认邮件、酒店客服回复、行程变更通知,输出是结构化字段,比如入住日期、离店日期、房型、入住人数、价格区间,最后写入下游数据表。
主力模型用的是某家云厂商的免费层模型,付费模型作为兜底。之所以敢让免费模型挑大梁,是因为抽字段这类任务容错性相对高,模型哪怕措辞绕一点,最终产出的JSON基本能用。免费模型不是没有缺点,它输出风格波动大,偶尔会答非所问,所以我让它后面挂了一个自校验模块,也就是常说的validator/judge。这个judge专门检查模型输出是否符合预期:字段齐不齐、类型对不对、数值合不合理。我标题里说的“我的代码判它失败”,指的就是这个judge模块。
我贴一段运行时日志结构,方便你理解整个链路:
{ "ts": "2025-01-16T14:02:31.182+08:00", "level": "INFO", "request_id": "req_20250116_140231_8f3a", "stage": "model_invoke", "model": "free-llm", "verdict": "PENDING" }每轮任务都会有这样一个入口日志,之后每个环节都带着同一个request_id往下走。这看起来是小事,但到了排查事故的时候,它就是你唯一的线索。
1.2 验证器为什么存在,又为什么容易成为薄弱点
模型生成内容是概率性的,同一个prompt,十次可能有十一种写法。验证器存在的意义,不是替模型把答案重答一遍,而是用一套确定性的规则去约束不确定性的输出。
你可以把模型想象成一个性格跳脱、偶尔喜欢发挥的实习生。实习生交上来的东西,你不能百分百信任,所以需要一个质检员在入库之前把一道关。质检员通常不应该出问题,因为它的逻辑是写死的,规则是确定的。但规则确定不代表规则正确。质检员的标准如果写错了,就只有两种结果:把坏品放出去,或者把好品全部打回。
前一种误判会污染下游数据,大家多少都遇到过;后一种误判的危害往往被严重低估。因为“打回”不是一个终止动作,它牵一发动全身——重试、换模型、回退分支,一连串更贵的路径会被触发。我这次的0.0211元,本质上就是“质检员的标准写错”,把一个合格品扔进了回料堆,然后整个生产线为了返工多花了一笔钱。
1.3 判定结果与后续动作的对应关系
我把Agent内部对判定结果的响应规则梳理成了一张表,这也是我这次事故里最关键的一张表:
| 判定结果 | 后续动作 | 成本特征 |
|---|---|---|
| PASS | 直接提交入库 | 零额外成本 |
| FAIL | 同模型重试1次,携带上次失败上下文 | 消耗免费配额 |
| FAIL(第二次) | 切换到付费模型兜底 | 产生真实计费 |
| FAIL(第三次) | 触发告警并暂停该任务 | 人工介入 |
注意看,PASS只有一条路,而FAIL有三个分支,越往下越贵。judge的一秒钟误判,影响的根本不是那一次调用,而是整条代价递增的链路。所以后来我把一条原则刻进了开发流程里:所有会产生成本的失败分支,都必须有日志、有告警、有熔断。这次事故恰恰是三条全没做到。
2. 我的验证器如何把正确结果误判成失败
2.1 出事的代码长什么样
先看坏掉的judge片段。这是它最早的简化版:
REQUIRED_KEYS = ["room_type", "check_in", "check_out", "guests", "price_band"] def judge(parsed: dict) -> str: missing = [k for k in REQUIRED_KEYS if k not in parsed] if missing: logger.warning("judge=FAIL reason=missing_keys request_id=%s missing=%s", task.request_id, ",".join(missing)) return "FAIL" if not isinstance(parsed.get("guests"), int): logger.warning("judge=FAIL reason=bad_type request_id=%s", task.request_id) return "FAIL" return "PASS"再看它对应的prompt版本(新旧两版我都贴出来):
旧版prompt(v3.2)要求模型输出英文key:
请提取以下邮件中的预订信息,输出JSON: 字段包括 room_type, check_in, check_out, guests, price_band新版prompt(v4.0)已经改成中文key,因为运营同事反馈中文字段更好读:
请提取以下邮件中的预订信息,输出JSON: 字段包括 房型, 入住日期, 离店日期, 入住人数, 价格区间发现问题了吗?prompt在2024年12月就升到了4.0,中间还迭代过一次输出示例。但judge文件里的REQUIRED_KEYS始终是旧版英文key。这就是典型的“改了一半”:
- 模型端升级了输出schema;
- 消费端judge没有跟着升级;
- 两边各跑各的,谁也没报错,直到账单出卖了真相。
2.2 模型“答对”的证据
那天14:02那轮任务,模型实际输出是这样的:
{ "房型": "大床房", "入住日期": "2025-01-20", "离店日期": "2025-01-23", "入住人数": 2, "价格区间": "200-300元" }这条输出放到业务里看,完全正确:房型、日期、人数、价格区间全覆盖,只要做个简单的字段映射就能入库。但我的judge接收到的parsed dict是这组中文key,它拿着旧清单逐个比对,发现“room_type”不在里面、“check_in”不在里面,于是当场返回FAIL。
日志里清清楚楚记着那行warning:
[2025-01-16 14:02:31] WARN judge=FAIL reason=missing_keys request_id=req_20250116_140231_8f3a missing=room_type,check_in,check_out,guests,price_band这就是“免费模型答对了,我的代码判它失败”的完整物证。模型没错,prompt没错,错的是我埋在judge里那五个过期的key名。
2.3 三类常见误判,都是格式偏执
把这次经历和之前踩过的坑放一起,我发现Agent的validator误判大致有三类,全是“格式偏执”:
| 误判类型 | 典型表现 | 触发原因 |
|---|---|---|
| schema漂移 | 模型输出新字段名,validator还检查旧字段名 | prompt升级,检查逻辑没同步 |
| 包裹与前后缀 | 模型在JSON外加了说明文字或markdown代码块 | 解析正则写得太死,没做宽松提取 |
| 类型边界过严 | 人数写成"2"而非2,价格写成"200-300元"而非字符串 | validator要求强类型,模型按人类习惯输出 |
免费模型更容易触发这几类问题,因为它的输出风格比付费模型更不稳定,今天老老实实按示例来,明天可能就在JSON前面加一句“好的,这是您需要的结构化数据:”。这导致解析层和校验层都容易出幺蛾子。
有意思的是,我以前处理过“模型输出包裹在markdown里导致json.loads失败”的问题,当时修了正则,但没过问为什么模型会加外包装。现在回头看,免费模型的问题是信号不是干扰——它恰恰说明prompt对输出格式的约束不够强硬,只是当时我没把这件事上升到“格式鲁棒性”来对待。
3. 两条账本:一次误判如何触发真实扣费0.0211元
3.1 免费模型、配额和fallback链
很多人对“免费模型”的理解是零成本,其实不准确。免费模型本质是配额制:每分钟有请求数上限,每天有token额度,超过后要么限流,要么按量计费。这就意味着免费额度也是一种隐形成本,它被无效重试消耗掉之后,后面真正需要它的任务就只能走付费通道。
我的重试链设计是:
- 第一轮调用免费模型免费模型,judge返回FAIL;
- sleep 3秒后携带失败上下文重试,仍然是免费模型,还需要再消耗一轮免费配额;
- 若再次FAIL,触发fallback,切换到付费模型兜底。
这套链路平时跑得挺顺,我也没想过里面藏着什么炸弹。炸弹的引信就是那个过期的judge:它会把“语义正确但key名变了”的输出反复判成FAIL,于是免费模型一遍一遍被冤枉,重试链一遍一遍往下推。
3.2 0.0211元的费用拆解
账单里的0.0211元,精确拆开是这样几笔:
| 成本项 | 金额(元) | 说明 |
|---|---|---|
| 付费模型fallback调用 | 0.0174 | 携带两轮历史上下文,输入约7195 token,输出约375 token |
| 重试调度与消息队列 | 0.0021 | 同时间窗内多出的两轮调度、消息推送计费 |
| 日志存储与采集计量 | 0.0016 | 多出的两轮日志写入与30天存储分摊 |
总计0.0211元。
为什么fallback这次调用花了0.0174元?因为重试不是从头重来,而是把上一轮的模型输出、judge判定结果、用户原始输入全部代入对话历史,让模型自己纠错。我算了算,每多一轮重试,输入token大概膨胀20%到30%。等到fallback真正出场时,输入已经从第一轮的2800 token膨胀到7195 token。按付费模型输入0.002元/千token、输出0.008元/千token的单价,累计就是这个数。
3.3 静默失败的本质:任务成功掩盖了系统缺陷
这一节才是整篇文章我最想说的东西。0.0211元不是重点,“静默”才是重点。
这轮任务最后的结果是成功的——付费模型兜底成功,结构化字段入库,第二天早上数据报表一切正常。监控面板上没有任何异常,任务失败率为零。如果我没有去翻账单,这笔钱就会永远躺在那,这个bug就会永远潜伏在代码里,直到哪天免费配额被无效重试耗光,或者付费模型额度被刷爆,事情才可能暴露。
这就是静默失败的可怕之处:它不吵不闹,用任务表面的成功掩盖了系统内部的缺陷。Agent系统里,显式失败反而安全,因为你会去处理它;静默失败才是最危险的,因为你根本不会去复盘一个“看起来成功”的任务。
再算一笔放大账。一次误判0.0211元,看着微不足道。但如果这个Agent一天跑288次(五分钟一轮),误判率5%,一天就是14次误判,光fallback就是每天0.3元、一个月9元、一年过百元。这还只是单Agent单任务的账,还没算免费配额被无效消耗后,真实任务被迫高频走付费通道的连锁反应。一旦业务量大起来,这种“静默的浪费”会从零钱变成成本黑洞。
4. 排查复盘:从账单到日志,再到一行业务代码
4.1 第一步:账单反查,锁定时间窗
发现那笔0.0211元扣费后,我没有直接去改代码,而是先把账单打开看明细。账单里的计费项写的很清楚:某云模型推理按量计费,时间戳落在01-16 14:02到14:03之间,请求数一次,token消耗总共约7570。看到时间窗和模型名,我就知道这大概率不是正常的免费模型调用,于是去Agent日志里捞这个时段的所有相关记录。
这里有个基础操作值得多说一句:查日志前先确定时间窗,并且尽量精确到分钟。别拿着大半天的时间范围去捞,日志量一大,检索效率和准确率都会下降。
4.2 第二步:用request_id串起整条链路
幸好我从第一天就给每轮任务生成了request_id,每次模型调用、每次judge判定都打上同一个id。没有这个id,今天要还原这条链路会痛苦十倍。
我用的是日志平台自带检索,等价于在采集端做了过滤。如果你本地排查,可以参考这条命令:
grep "req_20250116_140231_8f3a" /var/log/agent/agent.log | jq '{ts, stage, model, verdict}'输出结果很直观:
{"ts":"14:02:31.182","stage":"model_invoke","model":"free-llm","verdict":"PENDING"} {"ts":"14:02:33.046","stage":"judge_result","model":"free-llm","verdict":"FAIL","reason":"missing_keys"} {"ts":"14:02:36.210","stage":"model_invoke","model":"free-llm","verdict":"PENDING","retry":1} {"ts":"14:02:38.734","stage":"judge_result","model":"free-llm","verdict":"FAIL","reason":"missing_keys"} {"ts":"14:02:41.583","stage":"model_invoke","model":"paid-llm","verdict":"PENDING","fallback":true} {"ts":"14:02:48.201","stage":"judge_result","model":"paid-llm","verdict":"PASS"}三行关键日志,直接把事故链摆在了眼前:
- 免费模型第一次调用,judge FAIL;
- 免费模型第二次重试,judge还是FAIL;
- 切换到付费模型,一次就PASS。
付费模型答得也不比免费模型神圣,它能PASS的唯一原因是当时fallback分支里临时加了一行“trusted_model_skip_judge=True”,直接跳过了校验。也就是说,只要切了付费模型,不管输出啥都放进来了。这个判断本身也是有问题的,只是当时代码写得匆忙,把“兜底”写成了“放水”。
4.3 第三步:从日志翻出模型原话和我的误判
拿到judge的FAIL记录后,我直接去翻了同request_id下模型原始输出字段的存储。日志里其实保存了agent_reply的预览快照:
{"agent_reply_preview": "{\"房型\": \"大床房\", \"入住日期\": \"2025-01-20\", \"离店日期\": \"2025-01-23\", \"入住人数\": 2, \"价格区间\": \"200-300元\"}"}把模型原始输出和judge的missing_keys对照着看,结论立刻清晰:模型输出的五个字段全部信息完整,只是key名从英文变成了中文。judge检查的是room_type、check_in,而输出里是房型、入住日期——不是信息缺失,是schema变了,校验清单没跟上。
这一刻的感受挺复杂的。一方面庆幸日志够了,能还原真相;另一方面有点恼火,因为这个问题从头到尾只需要改一行配置。模型在免费层勤勤恳恳干对了活儿,被我这段代码硬生生打了两个“不合格”的标签,最后还浪费了一次付费调用。
4.4 关于日志采集的题外话
这次能这么快定位,有个前提是日志不是散落在各台机器上,而是统一采集到日志平台。我在Agent所在的宿主机上部署了filebeat做日志采集,输入指向Agent的运行日志目录,输出写到集中日志系统。配置文件非常短,核心就这些:
filebeat.inputs: - type: filestream id: agent-log paths: - /var/log/agent/*.log output.elasticsearch: hosts: ["http://logging.internal:9200"]具体采集组件用什么不强求,但有一个原则必须守住:Agent日志的采集管道要独立于业务系统。业务系统挂了,日志采集不能跟着挂;反过来,日志采集抖动也不能拖垮正常业务。这起事故能翻出来,靠的是日常采集管道稳定运行,哪一环断了,这张账单就会变成无头悬案。
5. 修完代码之后做的三处加固
5.1 验证器改造:从格式校验走向语义校验
第一件事就是把judge里的REQUIRED_KEYS换掉,但这只是止血。真正的修复是让校验逻辑不再只认一套key名,而是先做语义对齐:
FIELD_ALIASES = { "room_type": ["room_type", "房型", "房间类型"], "check_in": ["check_in", "入住日期", "到店日期"], "check_out": ["check_out", "离店日期", "退房日期"], "guests": ["guests", "入住人数", "客人数量"], "price_band": ["price_band", "价格区间", "预算范围"], } def judge(parsed: dict) -> str: missing = [] for canonical_key, aliases in FIELD_ALIASES.items(): if not any(alias in parsed for alias in aliases): missing.append(canonical_key) if missing: logger.warning("judge=FAIL reason=missing_keys missing=%s", ",".join(missing)) return "FAIL" ...这套改造的核心思路是:校验器应该校验“信息是否齐全”,而不是“字段名是否和我想的一样”。字段名是模型的输出表现层,今天叫room_type,明天叫房型,后天可能叫“房间数量与类别”,语义没变就不该被打回。以后prompt再改schema,judge这边只需要在别名表里加一行,不需要改判断逻辑。
5.2 重试链加固:熔断、重试预算与语义反馈
第二件事是给重试链上保险。之前的设计是“失败就重试,重试失败就切换”,现在改成了:
- judge的FAIL消息必须带失败原因,没有原因的FAIL直接记WARN并视为系统异常;
- 免费模型重试最多2次,第三次无论结果直接走告警并暂停该任务;
- fallback当天累计触发超过3次,自动熔断,防止失控;
- 重试历史作为上下文喂给模型时,要把“缺了哪个字段”这种原因带回去,而不是甩一句“请重新生成”。
以前模型被要求“请改正后重新输出”却不知道自己错在哪,就得全靠猜。现在judge会把具体缺失字段写进重试上下文,模型纠错的成功率会明显提高,无效重试自然减少。
5.3 日志补全:judge决策过程必须完整落盘
第三件事,也是和我这个标题关系最直接的一件:把judge的决策过程完整落盘。以前只记录结论(FAIL或PASS),不记录依据,出了问题还得翻模型原始输出人工对照。现在每行judge日志都补齐了verdict_reason、schema_version、check_item、judge_input_preview四个字段:
{ "ts": "2025-01-17T10:00:01.001+08:00", "request_id": "req_20250117_100001_a1b2", "stage": "judge_result", "verdict": "PASS", "schema_version": "v5", "check_item": "required_field_check", "judge_input_preview": "{\"房型\":\"大床房\",\"入住日期\":\"2025-01-20\"}" }一句话总结这次加固的体会:日志不能只记录“我判你失败”,更要记录“我为什么判你失败”。没有原因日志,只能看到现象,看不到故障根因;有了原因日志,下次再出问题,至少能少走半个小时弯路。
5.4 用历史样本做了一次回归
最后,我把过去一个月存下来的200条真实模型输出重新跑了一遍新judge。结果很说明问题:旧judge的误判率是3.5%,新judge降到0.2%,而那0.2%全部是真正缺字段的输出。也就是说,之前每100条正确输出里,就有3到4条因为字段名变化被误杀,而我完全不知道。
这次回归也暴露了另一个数字:200条样本里,schema漂移导致的误判占到了七成以上,远比模型本身答错的概率高。这让我调整了后续的优先级——与其频繁优化模型prompt,不如先把judge的语义校验能力写好。模型偶尔犯错,成本是单次调用;judge系统性误判,成本是整个Agent链条上的连锁放大。
最后补一刀:账单是最诚实的监控
修复上线到今天,我又跑了三周,固定每天看一眼账单。可以负责任地说,类似的小额异常扣费没有再出现过。但真正让我改变的不是那0.0211元,而是这起事故逼我回答了两个问题:我的代码有没有在某个分支里反复执行“我知道会失败但依然选择重试”的无效动作?那些执行了但没人看的日志,到底是浪费磁盘还是沉默的哨兵?
我现在养成的习惯是:每个Agent项目上线,先不看模型效果,先看一遍重试链和judge链路有没有完整日志;每次想起来就顺手翻一遍账单,不是抠那两分钱,而是确认那些“静默的分支”还没有在某个角落里背离设计预期。免费模型不是没有成本,judge也不是越严格越好——它们的成本都藏在你看不见的分支里。希望这篇日志能帮你少踩一次同样的坑。