这段时间一直在捣鼓自养的Agent,说白了就是自己搭的一套无人值守自动化任务系统,定时把脏话丢给大模型处理,再把结果归档。干这行最爽的事,是看着它自己跑完一天的活;最闹心的,是它明明把事办对了,却被我写的校验代码“错杀”,然后一声不吭地重试了两轮,最后账上悄悄少了¥0.0211。
别小看这二分钱,它背后藏着一整套关于Agent日志、结果校验、失败重试和成本追踪的问题。今天就把这次事故从头到尾扒一遍,讲讲免费模型答对了、代码却判它失败的真实原因,以及我怎么从一条日志里把问题揪出来修的。这篇文章适合所有正在做Agent开发、搭过自动化脚本、或者在大模型应用里被“静默失败”坑过的朋友,哪怕你是刚入行的新手,也能照着排查思路少走几轮弯路。
1. 一次“错杀”的完整现场:免费模型答对了什么,代码判错了什么
1.1 我的Agent长什么样:调度、模型、校验三件套
先交代一下背景。这套自养Agent以前我习惯叫“个人助理机器人”,主要由三块拼起来:调度器、模型调用层、结果校验层。调度器是系统自带的定时任务,每天固定时间触发任务管道;模型调用层通过一个第三方兼容网关去访问某个免费开源模型的API,网关负责统一请求格式、做流量限制,还能统计每次调用的实际消耗;结果校验层是我自己写的,负责把模型返回的文本变成结构化数据,再判断这个结果能不能入库。
为什么明明可以用官方API直连,还要套个网关?因为我手上有好几个不同来源的模型,有的限速、有的免费额度少、有的格式不太稳定。网关怀一层的最大好处是,切换模型时不用改业务代码,只需要改一个上游配置;坏处是多一次转发,就多一笔按量计费,也多了失败的可能。这次出事的环节不在这两层,而在第三层——我自己写的校验代码。
1.2 模型“答对了”与代码“判失败”的认知偏差
当时给Agent布置的任务很简单:把一条用户留言自动归类,输出一个JSON对象,字段包括type、score、summary。真实情况是模型确实看懂了这条留言,判断它是投诉,总结也写得贴合原意,但它返回的内容是下面这个样子的:
好的,我来分析这条留言。用户情绪比较强烈,表示产品多次出问题,应该属于投诉类型。 { "type": "投诉", "score": 0.92, "summary": "用户因产品多次故障产生强烈不满,要求尽快给出解决方案。" }这有什么问题?问题大了。我的校验代码里写死了只认英文枚举值"complaint",而且要求返回纯JSON,不能有多余文字。模型先做了一段推理再给结果,语义完全正确,但格式不符合预期。校验逻辑一看:JSON解析失败、字段值不在白名单里,直接判定失败,把整个结果给否了。
这类“答对但格式不对”的情况,在免费模型身上特别常见。收费模型通常做了指令遵循优化,你让它输出JSON,它真的能管住自己不多说一句;但免费模型为了显得“聪明”,特别喜欢把推理过程和答案混在一起,还会顺手给你包个Markdown代码块。这本身不算硬伤,真正硬伤的是我的校验逻辑太死板——拿一把精确到毫米的尺子去量一块本来就可以有误差的材料,量出来自然“不合格”。
1.3 失败判定代码长什么样:一段“写得太满”的校验逻辑
当时这段校验代码不算长,我简化一下给大家看看,问题非常典型:
import re import json def parse_and_validate(raw_text: str) -> dict: # 只认纯JSON,多余字符全部不支持 if not raw_text.startswith("{") or not raw_text.endswith("}"): raise ValueError("response is not pure json") # 用正则硬找 type 字段 type_match = re.search(r'"type"\s*:\s*"(.*?)"', raw_text) if not type_match: raise ValueError("type field not found") type_value = type_match.group(1) # 字段值必须走白名单 if type_value not in ("complaint", "inquiry", "other"): raise ValueError(f"unknown type: {type_value}") # score 强制转 float data = json.loads(raw_text) score = float(data["score"]) if score < 0 or score > 1: raise ValueError("score out of range") return {"type": type_value, "score": score}逐条看这块代码,至少有四个隐患:第一,开头和结尾必须严格是花括号,只要输出里带一个解释句、一个代码块标记、一个尾注,整段就废了;第二,用正则去抓字段,遇到中文值“投诉”直接不匹配;第三,白名单只有英文枚举,没做同义词映射;第四,json.loads只会在整个文本都是JSON时才成功,所有“带前言的JSON”都过不了。模型这次就是栽在第一条和第三条的组合上。
很多初学者喜欢把校验逻辑写得“越严越好”,觉得严一点能挡住脏数据。亲身经历告诉你,校验的严格度必须和模型输出的稳定度匹配。免费模型的输出本来就飘,你越严,它死得越快,最终代价全变成重试调用费和半夜爬起来处理的痛苦。
1.4 ¥0.0211这个数字是怎么落到账上的
我是在第二天早上看账单时发现这笔异动的,金额很小,但足够扎眼:0.0211元,发生在凌晨那次任务执行期间。顺着时间线和网关调用记录往回捋,整个事故链路是这样的:
第一次,Agent调用模型,输入约4000 token、输出约800 token,语义正确但格式不达标,校验失败,网关侧按量计费约0.0058元,这是第一笔花钱。失败之后Agent自动进入重试流程,第二次调用加了“请严格输出纯JSON”的提示词,模型确实输出了纯JSON,但type值仍然是中文“投诉”,再次被白名单拦下,又花掉约0.0058元。第三次重试前,Agent触发了工具调用,去向量库里检索常见分类映射,这一步也有成本,约0.0024元;随后第三次调用模型,这次终于输出了英文枚举,但耗时变长,token略多,约0.0071元。
三次模型调用加一次工具调用,合计0.0211元。任务最终成功了,但原本一次就能干完的活,因为校验逻辑的问题变成了四次底层请求,成本翻了三倍多。问题在于整个过程没有任何告警,日志里只有一条简单的retry after failure,要不是我主动拉账单,根本不会发现。
2. “静默”才是这次事故最贵的地方
2.1 静默失败是自养Agent的常见默认配置
大部分自养Agent都会做“静默失败”,这是有历史原因的。无人值守系统半夜出点小问题太正常了,你要是每次失败都疯狂告警,短信费不说,人也会被搞疯。所以很多开发者的默认做法是:遇到异常就把错误吞掉,在日志文件里记一行备注,然后按预设次数重试,重试还不行就跳过任务。这套设计的初衷是“保留现场、不打扰人、把任务扛过去”,在绝大多数场景下它是对的,但也埋了个大坑:当失败的原因不是模型抽风,而是你自己的代码写错时,静默重试只会反复用错误逻辑去“验证”正确结果,花三次冤枉钱,然后留下一堆无法定位问题的日志。
我这次就踩了坑。校验抛出的异常被我写成了logger.warning("parse failed, retry later"),没有标记失败原因摘要,没有把原始输出存下来,也没关联任务ID。看到日志时,只有一行孤零零的告警,完全不知道模型到底返回了什么,这给后续排查增加了难度。准确说,不是静默机制本身错了,而是没人告诉它“哪些静默是允许的”,于是它把正常失败和需要人工干预的失败混在一起,一起吞了。
2.2 静默的副作用:不可观测、掩盖问题、数据污染
静默失败最隐蔽的副作用不是多花几毛钱,而是把真正的问题盖住。我这次如果不是凑巧去翻账单,这桩“错杀”可能会在每一天凌晨定时上演:模型天天答对,校验天天判错,重试天天多花三倍钱,你看日志,又什么都看不出来。要命的是,失败的这次任务并未入库,数据链路上出现了一天空档,后续的分析统计里这一天就是缺的。
这种状态有点像家里水管渗漏但你没装水表检测,楼下邻居还没找上门,你以为岁月静好,其实是隐患在加速积累。Agent系统里最贵的从来不是单次失败的二分钱,而是“失败不可见”导致的调试时间黑洞和生产数据可信度下降。你费劲把Agent建起来,不就是想让数据链条稳定可靠吗?一旦静默失败常态化,这条链上出现几个缺数据的节点,你人对系统的信任也就没了。
2.3 重试策略的三个反直觉坑
重试不是免费的,这个大家都懂,但重试策略有三个反直觉的坑,我这次一个不落全踩了:
第一,重试会放大成本。单次模型调用几分钱看着不贵,但任务一多、失败一多,重试调用就像滚雪球,月底账单自然会教做人。网上有人算过,哪怕每天只有100次任务、每次因格式问题多打两次模型,月成本也会翻三倍,日志量暴涨,噪音越来越多。
第二,缺少幂等保护的重试会造成重复写入。如果Agent任务网络不稳定、超时后自动重发,而接收端没有做去重,同一条留言会被重复处理,轻则统计翻倍,重则对用户造成骚扰。不是我吓唬你,我见过有人做活动推送的Agent因为重试把同一个优惠券发放消息推了两遍,运营同学直接被用户当成骗子。
第三,指数退避在长任务队列里会引发堆积。Agent类任务通常是一批一批推进的,指数退避适合单线程请求,但对一批延时任务来说,前一个任务卡住重试,后面的任务全排队,整体执行时间线会被拉得很长,最终表现为“早上起来发现昨晚的活全没干完”。
给Agent设计重试策略时,越早想清楚“什么情况值得重试、重试几次、重试后怎么防重复、重试还不能成怎么办”,后面就越省心。重试的目的是让系统有韧性,不是掩盖故障,这两者的边界一定要拿捏清楚。
3. 排查实录:把“错杀”从日志里捞出来
3.1 日志入口:从任务日志到Agent自己的输出文件
发现账单异常后,我第一反应是去翻Agent日志。自养Agent的日志分散在好几个地方,排查顺序错了容易白忙一场。我先看系统任务的执行记录,确认任务有没有正常触发;再看Agent进程的标准输出和错误输出,找异常堆栈;最后看应用日志文件,按时间点过滤出那几次调用。
如果你用的是类系统定时任务,执行日志一般会写入系统日志类文件里,可以用grep直接过滤任务名,也可以tail -f实时跟一段。Agent的stdout和stderr则要看当初启动时是否做了重定向。我见过不少人启动Agent时图省事,什么都不重定向,进程一重启历史日志就全没了,遇到问题根本无从查起。我的习惯是启动命令统一写成:
./run_agent.sh >> /data/logs/agent_run.log 2>&1 &这样标准输出和错误输出都进同一个文件,再配合路由字段或者时间戳做筛选,找问题会快很多。这次我就是在应用日志里搜到那三条parse failed告警的,时间点正好和账单上的扣费记录吻合。
3.2 对齐原始输出与校验结果:最快复现的方法
找到失败时间点之后,接下来要做的是复现并定位根因。我核对时发现在校验之前模型输出已经写进了临时文件,只有几百字节,但正是这一小段数据救了命。我把当时存档的原始输出翻出来,调出同一版本的校验函数重新执行一遍,把“模型原始输出”“期望格式”“代码实际判定”“判定原因”四项对齐,问题就一目了然了。
下面这个表格就能说明问题:
| 项目 | 内容 |
|---|---|
| 模型原始输出(截断) | “好的……用户情绪强烈……{ \”type\”: \”投诉\”, \”score\”: 0.92, …… }” |
| 期望格式 | 纯JSON,type字段为英文枚举complaint |
| 代码实际判定 | ValueError: response is not pure json |
| 真正原因 | 模型先输出推理语句再接JSON,且type值用了中文 |
这种对齐法看起来简单,但很多人会跳过。常见偷懒做法是直接把报错贴给模型看,让模型“猜”自己哪里错了,结果模型一通胡编,反而把问题带偏。正确做法永远是先把证明“模型输出到底是什么”的原始存档拿出来,让证据先说话,再让代码做“审判”。没有原始输出存档的排查,就算最后修好了,也是碰运气修好的,下次换个输出样式还会再挂。
3.3 修复方案:三层容错加结构化校验
修这问题,我没有简单地把白名单加上中文就完事,因为那只是把当前的坑填平,下次模型换成“把解释放在JSON后面”的样式照样炸。我重新设计了校验链路,分了三层:
第一层叫格式容错。原则是先用启发式方法把模型输出里最像JSON的部分截取出来,再交给json.loads解析,不再依赖字符串必须以花括号开头结尾。可靠做法是兼顾方括号和花括号配对截取最外层块,同时把Markdown代码块的三个反引号剥掉。第二层叫语义包容。type字段不再只认英文枚举,而是做一个映射表,把“投诉”“强烈不满”“要求退款”这类中文表达统一归一化成complaint。第三层叫兜底告警。所有仍然解析不了、仍然语义不明确的输出,一律不走重试静默逻辑,而是由另一个告警通道直接通知我处理。
这是修复后的核心代码片段:
def extract_json_block(raw_text: str): # 去 Markdown 代码块标记 text = re.sub(r"```(?:json)?", "", raw_text).strip() # 从文本中找出最可能是一个 JSON 对象的区间 start, end = text.find("{"), text.rfind("}") if start == -1 or end == -1 or end <= start: raise ValueError("no json object found in response") return json.loads(text[start:end + 1]) def normalize_type(value: str) -> str: mapping = { "投诉": "complaint", "强烈不满": "complaint", "咨询": "inquiry", "请问": "inquiry", "其他": "other", } value = value.strip() return mapping.get(value, value) # 拿不到映射就原样返回,交给上层判断改完之后,同一个原始输出重新跑校验,直接通过,状态变为成功,不再触发重试。我顺带在代码里加了一个“判定依据”字段,每次失败都会写明具体卡在哪一层、哪个字段、什么原因,而不是只给一个parse failed。这个习惯强烈建议你也养上,后面排查问题能省大量时间。
4. 成本与日志的“微积分”:把一次0.0211元变成控制项
4.1 Agent单次执行到底算几笔钱
一次Agent任务的成本,远不止“模型调用的token钱”那么简单。按我这套架构拆开算,至少有五块:
- 模型API调用本身,按输入输出token计价;
- 第三方网关的中转服务费,通常是按次计费;
- 工具调用成本,比如向量检索、数据库查询、文件读写;
- 消息队列和无服务器函数的运行时长费用;
- 日志存储和检索开销,量大了之后也看得见。
用一个粗略公式来表达就是:
单次任务费用 = 模型调用费用 + 工具调用费用 + 网关服务费 + 运行时费用 + 日志费用
单次失败任务我这次花了0.0211元,拆开来看:主调用一次约0.0058元,重试两次约0.0129元,工具调用约0.0024元。免费模型虽然不直接收token钱,但网关转发、日志落盘、程序运行时长都有开销,这些加在一起,免费模型的实际成本并不是0。
4.2 从单次失败推算月浪费量
0.0211元看着不多,但如果这个bug不修,按一天执行20次任务算,每天至少有5次会因为格式问题触发额外重试,每次多花约0.0153元,一个月下来就是2.3元。看着也不多,但那是基于一个高频任务的假设。换成一天100次任务、失败率10%的业务系统,一个月凭空多烧掉45元以上,再把调试时间折算上,人力成本远高过token钱。
更重要的是隐性成本:每次错杀都会制造一个数据空洞,下游统计分析要花时间去清洗和补录;每次凌晨失败都可能吵醒值班人,一次睡眠损失无法用金钱衡量。我做一个简单表格,让大家直观感受显性成本和隐性成本的区别:
| 成本类型 | 表现 | 估量方法 |
|---|---|---|
| 显性 | API调用、网关费、日志存储 | 直接看账单 |
| 显性 | 重试导致的重复调用 | 统计失败率乘以单次成本 |
| 隐性 | 调试和修复时间 | 半小时起步,折合人力成本 |
| 隐性 | 数据空洞、统计偏差 | 下游任务返工成本 |
| 隐性 | 夜间告警、人的疲劳 | 无法量化,最贵 |
所以我建议所有自养Agent都把“每次调用预估费用”写进结构化日志,用类似记录日志的工具把它聚合成简单的看板。哪怕只是每天看一眼费用趋势,你也能在异常发生第二天就发现,而不是等到月底结账时目瞪口呆。
4.3 如何判断“免费模型”的真实成本
免费模型这个词很有迷惑性,它不收你token费,但它在“格式稳定性”上花的额外成本可能远超一个便宜的付费接口。模型答对了但校验判错的情况,免费模型比付费模型高得多,这意味着你要在解析层、重试层、人工兜底层投入更多精力。我用过几款开源或限免模型之后,最大的感受是:真正的成本不在于模型能力,而在于围绕它写的“消毒代码”。
对一个自养Agent来说,选免费模型不是不可以,但务必把“输出方差”当作一项隐性成本算进总账。如果你的任务下游接的是严格数据库结构,建议优先选指令遵循稳定的付费模型;如果任务只是摘要、泛化分类这种容忍度高的活,免费模型完全能胜任。遇到模型偶尔“多话”,不要急着骂模型,先把校验逻辑放宽到合理范围,很多时候问题从模型输出变成了管道太死,位置一变就好解决了。
5. 常见问题速查表与自养Agent守则
5.1 “模型答对但代码判错”的常见诱因
我把这次踩坑和平时看别人踩坑的经验整理成一个速查表,按“诱因—现象—定位方法—修复建议”四栏排好,遇到相同情况可以直接对号入座:
| 诱因 | 现象 | 定位方法 | 修复建议 |
|---|---|---|---|
| 输出带Markdown代码块 | JSON解析失败 | 打印原始输出,肉眼可见反引号 | 先剥掉代码块标记再解析 |
| 输出附带推理文本 | 字符串不是以花括号开头结尾 | 找到花括号最外层配对 | 用截取JSON块方式解析 |
| 字段值是中文/同义词 | 白名单校验失败 | 对比字段实际值 | 做语义归一化映射 |
| 字段名大小写变体 | 正则找不到字段 | 搜原始输出里的键名 | 统一转小写后再匹配 |
| score是字符串 | float转换失败 | 打印类型 | 统一做类型豁免 |
| 空数组或空对象 | 结构解析失败或逻辑出错 | 检查响应长度 | 增加空值分支处理 |
| 输出被截断 | JSON不完整,缺右括号 | 检查token上限 | 提高max_tokens或缩短提示词 |
| 模型跳过必要字段 | 缺字段报错 | 对照schema和输出 | 再次调用模型补全或忽略 |
| 模型虚构附加字段 | 强schema反序列化报错 | 看extra字段 | 忽略未知字段或宽容解析 |
这张表看着条目多,本质就一句话:校验逻辑要面向真实世界的输出建模,而不是面向理想输出建模。你宁愿多写几行容错代码,也不要逼每个免费模型都变成格式完美的机器。
5.2 自养Agent日志守则(五条硬规则)
经过这次教训,我给自己定了几条自养Agent的日志硬规则,分享出来供大家参考:
第一,模型的原始输出必须落盘,至少保留7天。宁可磁盘多占几KB,也绝不能在排查问题时拿不到证据。每次任务除了最终结果,还要把输入、输出、提示词版本一起存进记录表或文件,这相当于给每次执行拍了一张X光片。
第二,每次重试都要单独打点,写明第几轮、失败原因、累计花销。重试不是错误,但看不见的重试会成为成本和无尽的毒药。日志字段至少包含retry_count、fail_reason、estimated_cost、task_id。
第三,失败必须走告警通道,禁止静默吞异常。你可以区分“预期内失败”和“需要人工的失败”,但前者也要告警,只是频率可以低一些,比如“10分钟内同任务连续失败3次”才通知。让重试系统自动处理噪音是可以的,但人必须知道系统正在自动处理什么。
第四,每条日志都要记录判定依据。不要让日志里只有“校验失败”四个字,要把具体卡在哪个字段、期望值是什么、实际值是什么写清楚。我在修复时加的“判定依据”字段现在成了排查利器,报错一眼扫过去就知道是格式问题还是字段映射问题,根本不用重复跑代码。
第五,调用前预算每次任务的预计花费,执行时累加实际花费,写进结构化日志。预算和实际开销的偏差,就是问题出现的信号。早期发现偏差比月底结账发现超支要舒服得多,省下来的钱够买好几杯咖啡。
5.3 关于“免费模型”与“核心任务”的边界
免费模型适合自养Agent,但“适合”两个字是有条件的。我的建议是把它用在容错度高、效果可容忍、错了无伤大雅的任务上,比如生成摘要、草拟文案、初步分类。而涉及钱、用户隐私、数据库写入、对外通知的核心任务,一定要加人工复核或旁路规则兜底,绝不能把宝全押在一个输出方差很大的模型上。
具体实操技巧是“影子模式”:先让Agent在正式执行的同时把模型输出和旧规则结果同时跑一遍,让两个结果彼此对照,连续跑一周,统计双方的偏差率。偏差率低于阈值再正式切换成新模型,高于阈值就回到旧方案继续观察。这个方法不是我发明的,但实测非常管用,既能吃到免费模型的成本红利,又不至于在核心业务上翻车。前面提到的重试和日志规则,本质上也是在给“模型自由发挥”圈一个可控的边界——模型可以在边界内折腾,但边界本身不能破。
按我个人的经验,做完这轮修复之后,我养的那个小Agent再也没有因为“答对但格式错”静默烧过钱。事后最大的感受是,Agent开发里最耗精力的地方,往往不在模型能力上,而在人和系统之间那层校验与沟通逻辑上。今天再回头看那笔0.0211元,它教会我的不是省几分钱,而是把每一次静默失败都当成一次事故警报,把每一条日志都当作事故现场的证据保留下来。最后再分享一个小技巧:如果你也在自建Agent,不妨在日志里加一个字段叫result_card,把模型原始输出、校验中间结果、最终判定原因都压进一行JSON里,排查时只要看这一个字段就够了——这个习惯帮我省下的排查时间,远超过当时修bug花的半小时。