1. 安静的 ¥0.0211:免费模型的钱是怎么花掉的
我给自己养的 Agent 配了一个成本看板,每天按 task_id 对账,把每一分钱都追溯到一次模型调用上。按照设计,那套执行链路上的初筛与评判环节全部走免费模型,成本应该恒等于 0。所以当我看到某个任务突然冒出一笔¥0.0211时,第一反应不是"真便宜",而是"链路偏离设计了"。
这笔钱本身不多,真正让我坐直的是它不该出现。我先去拉了日志,找到当天的执行记录。我的 Agent 跑在服务器上,用 crontab 定时触发,stdout 和 stderr 统一重定向到agent-runtime.log,然后按天切分。查日志的命令也很常规,先grep 0.0211 agent-runtime.log,再顺着时间戳往前翻上下文。整个过程不到两分钟,我就看到了这么一段联动记录:
{"ts":"2025-07-02T14:23:11.482Z","task_id":"t-7293","event":"judge_result", "stage":"initial_review","model":"free-model-3.1","judgement":"fail", "expected":"¥20.11","actual":"20.11元","is_paid":false,"cost":0.0} {"ts":"2025-07-02T14:23:11.986Z","task_id":"t-7293","event":"fallback_triggered", "reason":"judge_fail","retry_round":1,"is_paid":true} {"ts":"2025-07-02T14:23:12.301Z","task_id":"t-7293","event":"llm_call", "model":"paid-model-4","phase":"regenerate_answer","input_token":1804, "output_token":392,"cost_usd":0.0211}看到第二行的fallback_triggered我就明白了:免费模型其实已经把答案给出来了,是20.11元,而我的代码期望的是¥20.11,因为字符串不相等,判定器打了个fail,任务流随即进入兜底分支,去调了一次付费模型重新生成。那 ¥0.0211 就这么静默花掉了。
这不是模型答错,是我的判题代码答错。
2. 免费模型其实答对了:判题逻辑的三层漏洞
顺着这个日志继续挖,我把那段判题函数翻出来,发现它在设计上就埋了三个坑,任何一个单独出现都不至于致命,但叠在一起就成了这次事件的完美导火索。
2.1 第一层坑:字符串精确比较
我最初的判题逻辑非常朴素:模型输出的内容和预期答案做精确字符串匹配。伪代码如下:
def judge(expected: str, actual: str) -> bool: return expected.strip() == actual.strip()expected="¥20.11",actual="20.11元",不管怎么strip(),这两个都不可能相等。从代码角度来说,这个判断"执行正确",但从业务角度来说,它完全不理解自己在判断什么。我期望的是一个带货币符号的字符串,模型给出的是"数字+单位"的自然表达,语义等价,字面不相等。
这类问题在自养 Agent 里出现的频率远比你想象的高。只要模型的输出是自然语言,而不是严格的 JSON 结构,字符串级判断就一定会漏。你让模型"提取金额",它可能返回20.11、20.11元、¥20.11、$20.11、20.110,甚至带千分位20.110。字面上五花八门,语义上都是同一个数。
2.2 第二层坑:正则太自信
发现精确匹配太脆弱之后,我给判题逻辑加了一层正则。原意是好的:想把字符串里的数字部分提取出来,然后做数值比较。但问题出在正则本身写得不够严谨,而且没有考虑单位换算和格式差异。
我当时用的是类似这样的提取逻辑:
import re def extract_amount(text: str) -> float | None: m = re.search(r"(\d+(\.\d+)?)", text) return float(m.group(1)) if m else None这套规则面对20.11元能正确提出20.11,但一旦输入变成¥20.11,\d+前面的¥不影响匹配,也能提出20.11,看似没问题。可如果模型输出20,11(欧洲风格小数逗号),或者20.11 RMB,又或者同时出现两个数字比如原价25.00,现价20.11,我的正则只会提取第一个数字,直接取到25.00,判个失败。
更隐蔽的是,这种"提取成功"会给你一种假安全感。你以为正则帮你兜住了格式差异,实际上它只兜住了一部分。真正的边界情况:小数点、千分位、货币符号前后空格、全角数字、金额和数量的歧义,全部没有处理。
2.3 第三层坑:没有语义等价兜底
字符串和正则都不可靠时,正确的姿势是引入一个真正的语义判断层。最直接的做法是让另一个模型来评判两个文本是否等价。但很多自养 Agent 会有个误区:觉得"让模型做评判"太慢、太贵、太不稳定,所以宁可用死板的字符串规则。
这导致了一个很拧巴的设计:输出侧用的是自然语言模型,判断侧用的是机械规则。模型可以自由表达,规则却要求它按固定格式来,中间必然出现对不上的情况。我在那次事件里遇到的就是最典型的:模型的表达能力远大于判题规则的接受能力,于是判题规则输给表达能力,然后整个链路为这个误判买单。
2.4 复盘:我为什么会写出这样的判题逻辑
说句实话,三层漏洞能同时存在,本质上是因为我偷懒。判题逻辑写得太"想当然",没有基于真实输出样本做回归。当时我做了个测试集,只有三条样本:
| 预期值 | 模型输出样例 | 判定结果 |
|---|---|---|
| ¥20.11 | ¥20.11 | pass |
| ¥20.11 | 20.11元 | fail(没测) |
| ¥20.11 | 20.11 RMB | fail(没写正则) |
我只测了理想情况,就把判题代码合进去了。那些"模型输出和预期不完全一致但语义等价"的样本,我想都没想。
这个心态很典型:免费模型本身不要钱,所以给"评判成本"也默认是零元,于是随便写了段字符串比较就上线了。但代码判题这部分不是免费模型的成本,是我埋进链路里的一颗定时炸弹。
3. 静默失败比显式报错贵得多:兜底重试链路拆解
很多人一看 ¥0.0211 这个金额,第一反应是"这么点钱,无所谓"。但你得知道这个数字背后是一条隐性的成本放大器:每一次静默误判,都可能触发一整串付费调用和上下文膨胀,而且你完全察觉不到。我这次只花了两分钱,是因为恰好只重试了一轮。如果重试逻辑再激进一点,这串链路可以轻易花掉几块钱。
3.1 兜底逻辑本来想解决什么问题
设计兜底分支的初衷是合理的:Agent 跑任务时需要一定鲁棒性,如果免费模型质量不稳,或输出不达标,就自动切到付费更强的模型重新生成一次,保证任务不下滑。
问题在于,兜底的触发条件写成了judgement == fail,而判题逻辑本身就充满了误判。相当于我用一扇漏风的门当保安,保安只要说一句"有问题",我就自动去消费贵的资源。
3.2 免费模型判分的"自我验证"假象
这层风险比字符串比较还隐蔽。我在某些环节里,还会让免费模型充当 judge,去判断"前一个模型给出的答案是否满足要求"。想法很美好:免费模型评分,省成本。但现实很骨感:模型自身能力有限时,它的评判置信度是可疑的。一个本身理解不了复杂指令的模型,怎么保证它对另一个模型的输出有准确的评判?
这就是典型的经济学陷阱:你在最需要可靠判断的环节,使用了最不可靠的判断源,只因为它的价格是 0。结果就是误判率升高,兜底分支频繁触发,付费用量反而上涨。免费模型省下来的钱,在付费兜底和重试里全赔回去,还不算时间损耗。
3.3 0.0211 是怎么累积出来的
把这次事件的账拆开算一下,它不只是"一次付费模型的调用费"。完整链路是:
- 免费模型完成初筛,输出
20.11元,调用成本 0; - 我的判题代码给出
fail,这次判定本身不直接花钱,但它的错误决策导向了下一步; - 兜底分支触发,带着 1804 个 token 的历史上下文去调用付费模型,要求重新生成答案;
- 付费模型输出 392 个 token,费用合计 ¥0.0211;
- 如果付费模型这次的输出又没通过判题,还会触发第二轮、第三轮重试,上下文越来越长,每次调用成本逐轮上升。
再加上整个过程的日志只在 final 结果里记录了一个cost_usd,没有任何 warning 级别的告警。如果不是成本看板恰好按天对账,这笔两分钱的花费根本不会被发现,而它的背后是一次完全不该发生的重试。
3.4 成本结构里最贵的其实不是模型调用
事后我把计算口径整理了一下。模型调用费固然是一部分,但比它更隐蔽的是三样东西:
延迟浪费。一次兜底调用至少多花几百毫秒到几秒。如果任务本身是批处理,短期看不出问题,但如果你在跑一个 1000 条数据的任务,每条都误判一次,就是多花几十分钟到几个小时,整个排期全部往后推。
上下文污染。重试时通常会追加"上一次输出不满足要求,请重新生成"的指令。但如果上一次输出其实是对的,这条追加指令反而会把模型往错误方向带——它可能会为了避免"格式问题"而改写正确的内容,生成一个"看着正确、实际更差"的结果。
信任损耗。这一点我能明显感知到:当判题逻辑反复误报,你会开始对 Agent 的每一次输出都不放心,只能把产物全部人工再审一遍。原本自动化的流程,最后变成了"自动干活,人工检查",效率比纯手动还低。
4. 我如何把日志从"事后追查"改造成"决策审计系统"
排查出问题之后,我没只修那个字符串比较的 bug,而是把整条链路重新过了一遍。这次事件最大的教训不是"不该用免费模型评判",而是日志应该承载可审计的决策过程,而不是只记录结果和费用。所以我把日志系统做了一次改造。
4.1 结构化日志:让每个关键决策点都有据可查
改造第一步,是给日志加统一的 schema。核心思路是:每条日志都要回答三个问题——发生了什么?为什么发生?后续影响了什么?
| 字段 | 说明 | 示例 |
|---|---|---|
| ts | 事件时间戳 | 2025-07-02T14:23:11.482Z |
| task_id | 关联任务 ID | t-7293 |
| event | 事件类型 | judge_result / fallback_triggered / llm_call |
| stage | 链路阶段 | initial_review / regenerate_answer / final_verdict |
| model | 实际调用模型 | free-model-3.1 / paid-model-4 |
| judgement | 判定结果 | pass / fail |
| expected | 期望值 | ¥20.11 |
| actual | 模型实际输出 | 20.11元 |
| branch | 进入的分支 | normal / paid_retry / human_review |
| is_paid | 是否产生付费调用 | true / false |
| cost_usd | 本次调用成本 | 0.0211 |
| context_tokens | 历史上下文长度 | 1804 |
有个关键点:expected和actual必须原始记录,不要只记一个布尔值pass/fail。如果只记结果,你事后只能看到"判题失败",却看不到模型当时到底输出了什么,排查就只能靠猜。而我这次能快速定位,就是因为日志里同时存了expected="¥20.11"和actual="20.11元",一眼就知道问题出在格式比较上,而不是模型理解错了。
4.2 判题逻辑修复:三步走
日志只解决"能看清",真正消除问题还得改判题函数本身。我把它改成了三层结构:
from typing import Optional import re def normalize_amount(text: str) -> Optional[float]: """将各种金额表达归一化为浮点数。""" if text is None: return None # 统一千分位与小数分隔符:先把千分位逗号去掉,再处理点号 cleaned = text.replace(",", "") # 提取所有数字,取第一个完整数字作为候选 m = re.search(r"(\d+(?:\.\d+)?)", cleaned.replace("。", ".")) if not m: return None try: return float(m.group(1)) except ValueError: return None def is_semantically_equal(expected: str, actual: str) -> bool: """语义等价的宽松判断。""" e_norm = normalize_amount(expected) a_norm = normalize_amount(actual) if e_norm is not None and a_norm is not None: return abs(e_norm - a_norm) < 1e-6 # 归一化失败时,退回小写去空白比较,但不再直接判 fail,而是交给人工 return expected.strip().lower() == actual.strip().lower() def judge_with_fallback(expected: str, actual: str) -> tuple[bool, str]: if is_semantically_equal(expected, actual): return True, "pass" # 拿不准时,标记为 review,而不是直接 fail return False, "needs_review"这套修复的核心变化是:把判题输出从二值fail扩展成三值pass / fail / needs_review。当代码判断不了时,不要自作聪明去 fail,而是把决策往上抛,进入人工复核或日志告警分支。当初写代码时总觉得"能自动化就自动化",现在明白了:自动化应该只做有把握的部分,没把握时把判断交给成本更高的环节(人工或更强模型),而不是用一把假尺子量所有东西。
4.3 给关键决策点加上预警阀值
改完判题之后,我还在兜底分支的入口加了一个"二次确认"逻辑。现在的行为变成了:如果判题结果是needs_review,不再立刻触发付费重试,而是先把这次事件写入一条warning级别的日志,再按策略决定是挂起人工审核,还是等批量任务的最后统一处理。
更重要的是,我给免费模型评判环节加了能力校验。每次让免费模型做 judge 之前,先跑一组内置的"金标样本",如果它连这些已知答案都判定不对,就不允许它进入 judge 流程,直接降级成"仅输出参考结果,不决定分支走向"。这个改动看起来简单,实际效果立竿见影——免费模型的误判率从之前的高位掉到了可控范围。
4.4 离线回放:把历史日志变成回归测试集
这次排错结束后,我做了一件投入回报比非常高的事:把过去两周的日志全部捞出来,清洗成一份离线数据集。每条样本包含当时的expected、actual、最终判定和人工复核结果,然后用修复后的判题函数在历史数据上跑了一遍。
这个回放过程找出了三个我完全没预料到的问题:
- 第一个是我已经知道的格式差异;
- 第二个是模型在极少情况下输出
20.110这种带尾零的格式,旧正则也能误判; - 第三个是金额后面带括号备注,比如
20.11(含税),我的提取逻辑会漏掉数字,而修复后的归一化函数能正确处理。
每一类问题都在回放中被标记出来,我再针对性补规则。补完之后,历史数据判题准确率从约 88% 提升到 99.2%。剩下来的 0.8% 全是needs_review,不会再直接触发付费兜底。
这个流程的建议是:任何一次静默失败修复后,都应该留一套回归样本集。否则这周修了字符串比较的坑,下周换了模型供应商,可能又出现新的格式变体,你又要从零排查一遍。有数据集在手,改动跑一次回放就知道有没有引入新问题。
5. 给自养 Agent 的几条可落地建议
这次的 ¥0.0211,本质是给所有自养 Agent 开发者提了个醒:免费模型不是免费午餐,它只是把成本从 token 费用挪到了你的判断逻辑和运维复杂度里。如果判断逻辑不严谨,省下的钱会通过兜底、重试、人工复核加倍花掉。
综合我踩过的坑,有几条经验可以分享:
第一,判题失败和模型答错是两回事。日志里出现judgement=fail时,先别急着怪模型。去查actual字段,看模型到底输出了什么。如果输出内容语义正确只是格式不同,那是你的判题逻辑该修,不是模型要换。
第二,任何兜底分支都要带告警。在代码里触发付费调用之前,至少先写一条warning级别的结构化日志。这次如果能早一点看到fallback_triggered,就不会静默花掉那两分钱。告警的目的不是阻止兜底,而是让每次兜底都留下审计痕迹,事后能复盘到底值不值得兜底。
第三,三值判定比二值判定更安全。pass / fail看起来清晰,实际上逼迫代码在不确定时必须选边站,一选就错。引入needs_review之后,不确定的情况直接上抛,把决策权交给更可靠的人或模型,链路稳定性显著提升。
第四,免费模型当 judge 之前先做能力测试。如果模型连金标样本都判不对,它的判词就是噪声。让它只负责提供参考意见,别让它决定分支走向。
第五,回归数据集是自养 Agent 的护城河。每次任务产生的日志不只是给运维看的,更是你调整规则时的训练样本。攒一批带人工复核结果的日志,未来每次改动判题逻辑、模型参数、提示词,都能先离线回放再上线,避免同样的坑反复踩。
现在再翻看那笔 ¥0.0211 的日志,我已经不觉得它是一笔冤枉钱。它让我把整条链路的决策逻辑重新梳理了一遍,还顺手搭起了回归测试和告警机制。如果下次再看到非零费用出现在"应该全程免费"的任务上,日志会直接告诉我问题出在哪一个决策点,而我的判题代码,也不会再轻易把正确答案拒之门外了。