事情发生在一个工作日的下午,我在整理自养 Agent 的日志,准备确认一批定时任务的触发情况。结果在运行记录里看到这么一行描述:“预计每天 7 点到 7 点执行”。我心里一愣,因为对应的 cron 表达式分明写的是0 7-17 * * *,每天从早 7 点到晚 17 点,每个整点跑一次。免费模型把任务理解成了 7 点到 7 点——等于原地打了个盹。
这个场景听起来像段子,但做 Agent 开发的人都知道,这不是偶然。今天这篇文章就以这次“cron 被说成 7 点到 7 点”的日志为切入口,聊聊自养 Agent 在接入免费模型时遇到的那些坑:cron 表达式为什么容易让模型翻车、日志系统怎么帮我把问题揪出来、以及修复过程中总结出来的一套防幻觉机制。适合正在自己搭 Agent、用低成本模型做自动化任务、并且每天和 crontab、日志、任务调度打交道的朋友。
1. 现场还原:自养 Agent 里的一条奇怪日志
1.1 自养 Agent 到底是个什么东西
先说清楚我做的这个“自养 Agent”是什么。不是那种套壳聊天机器人,而是一个自己维护的、由大模型驱动的任务执行体。它能接收用户用自然语言下达的计划类需求,把需求解析成具体的指令,再调用我提前注册好的工具去执行。这里面比较关键的一环是“定时任务”:用户说“每天早上 7 点到下午 5 点之间,每小时生成一次数据报告”,Agent 就要把它翻译成一条 cron 表达式,交给底层的调度器去跑。
之所以叫“自养”,是因为模型接口、工具注册、任务编排、日志链路全是我自己搭的。模型用了免费或者低成本的通用模型接口,毕竟个人项目或者内部工具没有必要为每一轮调用付高价。这种模式的好处是成本低、可控性强、数据不轻易外传,坏处也很直接:模型能力上限摆在那里,尤其是在格式转换、精确数值处理这些“细活”上,翻车概率比想象中高得多。
这次出问题的任务,是一个每天重复的数据汇总任务。用户意图很简单:工作时段内每个整点汇总一次。我拆解出来的 cron 也完全正确:0 7-17 * * *,表示“每天 7 点到 17 点之间的整点执行”。问题出在 Agent 写给用户看的那句自然语言描述上。
1.2 日志里出现了一个自相矛盾的时间区间
为了能事后复盘,我给 Agent 的每个关键步骤都打了结构化日志:请求进来时记录输入,模型调用后记录原始输出,工具调用时记录参数,最终任务落库时再记录一份完整状态。这个习惯在这次排障中救了命。
那一天的日志长这样:
{ "request_id": "1f8c3ab2-9e4d-4b7a-8c2f-0d6a3b7e9a1c", "ts": "2025-04-08 07:00:00.123", "task": "daily_report", "step": "schedule_generate", "model": "free-model-7b", "input": "每天早上7点到下午5点之间,每个整点生成一次报告", "output_cron": "0 7-17 * * *", "natural_lang": "预计每天 7 点到 7 点执行,每小时一次" }注意看output_cron字段和natural_lang字段:一个是对的,一个明显是错的。模型生成的 cron 表达式完全正确,但它在给自己这段操作写“人话解释”时,把“7-17”理解成了“7 点到 7 点”。我盯着这行日志看了好几秒,确认不是展示层的 bug,也不是我自己拼接字段拼错了。
问题的本质是什么呢?模型在生成 cron 这一步是合格的,但它在进行“自我转述”的时候出现了语义坍缩。如果把 Agent 当成一个团队来看,这个现象就像:员工正确理解了任务并写好了日程,但给老板汇报的时候把时间说错了。老板要是信了这句汇报去安排下一步,整个链路就崩了。
1.3 为什么这种错误会“悄无声息”地混过去
这个错误之所以能在日志里潜伏下来,有三层原因。
第一层,执行和描述脱钩。cron 表达式被调度器直接消费,实际任务在 7 点到 17 点之间确实每小时跑一次,模型的错误描述对“物理执行”没有影响。只要没人去人工核对描述文本,这条错误就永远活在自己构建的假象里。
第二层,Agent 的多轮链路上存在信息损耗。如果后面有另一个模块基于这个描述做决策,比如“如果任务窗口小于 8 小时则告警”,那么错误的描述会被当成真实数据参与计算,最后得出一个莫名其妙的告警规则。
第三层,大部分免费模型在“表达自己做了什么”的时候特别喜欢自由发挥。它们不知道自己在转述一个必须严格精确的格式,模型会觉得“说个大概就行”,而这恰恰是自动化系统最忌讳的。
2. 为什么免费模型会在 cron 这种格式上翻车
2.1 cron 表达式对模型来说是个高危格式
先看 cron 表达式本身。一个标准的五段式 cron 长这样:
分 时 日 月 周 0 7-17 * * *每一段都有自己独立的取值范围和特殊符号,*表示任意,-表示区间,,表示枚举,/表示步长。人类程序员看习惯了觉得这是基础常识,但对语言模型来说,这是一个高歧义、高压缩的信息编码格式。
模型在训练过程中见过无数 cron 示例,但它并不知道“7-17”里的 17 是一个时间点,还是 1 和 7 两个数字的组合。当训练语料里出现频率更高的“7 点到 7 点”这类自然语言描述时,模型在输出阶段就会产生拟合倾向。说白了,模型对这个格式的理解是基于“统计规律”的,而不是基于“解析规则”的。一旦输入样本分布不均衡,它就会在概率空间里蹦出一个看起来合理但完全错误的结果。
我自己复盘过几次类似现象,发现免费小模型对数字区间的出错率明显高于对单个数字的处理。比如“每天 3-5 次”这样的区间,它有时候理解为“3 点到 5 点”,有时候理解为“3 次和 5 次各执行一遍”。这种问题在纯对话场景里无所谓,大家沟通一下就能纠正,但在 Agent 这种自动化链路里,错一次就会持续错下去。
2.2 免费模型的性价比陷阱:省下的钱都变成了排查时间
免费模型的优势谁都清楚:零成本、拿来即用、适合批量处理和冷启动。但它的代价也很现实:数学推理弱、格式跟随不稳定、指令遵循概率化。
我见过很多团队在做 Agent 原型时拍板接入免费模型,理由是“先用便宜的跑通,后面再升级”。这个思路本身没错,但容易低估一个事实:Agent 的任务链路比单轮聊天长得多。单轮聊天里模型说错一句话,用户能当场纠正;Agent 链路里模型说错一个参数,下游工具会用这个错误参数去执行,如果没有校验机制,错误就会被放大成生产事故。
这次 cron 事件就是个教科书级别的例子。模型在“生成 cron”这个子任务上是合格的,却在“描述 cron”这个子任务上失误了。如果整个 Agent 只有生成环节、没有描述环节,问题根本不会暴露。但现实是 Agent 天然需要对外展示、需要自我记录、需要把任务状态同步给其他模块,于是模型多走一步就多踩一个坑。
2.3 真正的隐患不在生成,而在“转述”
沿着刚才那条日志继续深挖,你会发现一个更值得警惕的现象:模型生成的 cron 是对的,但它自己并不知道自己生成的是对的。这种“言行不一”放在 Agent 架构里是非常危险的。
想象一下后续链路:Agent 生成了正确的 cron,调度器按 7-17 点正常执行。到了下午 2 点,另一个模块来问这个任务的状态,Agent 查了一下日志,根据自己之前写的描述回答“这任务在 7 点到 7 点之间执行,现在已经结束了”。于是模块跳过了当天的数据检查。用户晚上一看,报告虽然生成了,但相关的状态标记全乱了。
所以别把这类错误当成“文本瑕疵”。在 Agent 里,模型说出来的每一句描述性内容,都有可能被当成事实喂给下一个环节。这就像你在代码里写了一行错误的注释,而且这行注释还会被执行。
3. 日志系统如何帮我抓到这只“虫子”
3.1 我为什么坚持给 Agent 全链路打日志
如果你问我做 Agent 开发最重要的基础设施是什么,我毫不犹豫会答:日志。Agent 是一个非确定性系统,同一个输入、同一个模型,每次输出都可能不一样。没有日志,你面对的就是一个黑盒,排障全靠猜。
我维护的自养 Agent 有一个硬性约定:每个请求从进入系统开始,就分配一个全局唯一的request_id,然后所有相关日志都挂在这个 ID 下面。模型调用、工具调用、任务状态变更、最终执行结果,一层层全部落盘。这样的好处是,任何时候用户反馈“任务没跑”,我可以顺着request_id把整条链路看一遍,快速锁定是哪一步出了问题。这次能发现“7 点到 7 点”的描述,也是因为我把 Agent 的“自然语言转述”单独作为一步日志记录下来,否则模型输出的只是临时的 prompt 内容,跑完就算了,根本没有复盘的机会。
在日志里我坚持一个原则:不光记录“最终结果”,还要记录“模型原始输出”。有时候 Agent 的外部封装会对模型输出做二次处理,比如提取 JSON 字段、补全默认值,如果只记录处理后的结果,你根本看不到模型原始的“自由发挥”。而恰恰是那些未经处理的原始输出,才是发现问题的最好线索。
3.2 快速定位问题日志的三种办法
发现日志里出现“7 点到 7 点”这种描述很偶然,我是因为在翻看定时任务状态时才注意到的。但如果你想主动排查这类问题,有几个方法非常实用。
第一个是关键词检索。日志文件不大时直接 grep,搜索“7 点到 7 点”“0 点”“24 点”这类异常描述词:
grep "7 点到 7 点" agent.log grep "natural_lang" agent.log | tail -20第二个是结构化查询。我打日志时习惯写成 JSON 行,每行一个完整的 JSON 对象,这样可以直接用 jq 做字段级筛选:
cat agent.log | jq 'select(.step == "schedule_generate") | {output_cron, natural_lang}'第三个是时间窗口配合请求量统计。如果某类任务在特定时间段内触发率异常,可以统计该时间段的日志条数和任务状态。定时任务的执行记录本身就带有时间戳,用时间戳做聚合,能快速发现“某段时间根本没有执行记录”这种异常。
3.3 日志设计上的两个细节:结构化和可检索
这里多提醒两句,因为我见过不少 Agent 项目日志做得一塌糊涂,出了问题完全无从下手。
第一,一定要结构化。纯文本日志看着方便,但等你需要统计模型描述出错率、按任务类型聚合执行结果时,纯文本会把你逼疯。我推荐 JSON Lines 格式,一行一个 JSON 对象,每行独立完整,既能被grep直接捞,也能被jq、Python、日志平台原地解析。
第二,字段要自洽。像这种 Agent 场景,日志字段至少要包含模型名称、任务类型、输入摘要、输出摘要、关键参数、耗时、请求 ID、时间戳。字段命名保持一致,不要这次叫model_name,下次叫llm,那是给未来的自己埋雷。
4. 排查与修复:让 Agent 不再“睁眼说瞎话”
4.1 先确认这不是一次偶发幻觉
发现日志异常之后,我的第一反应不是立刻改代码,而是先统计一下这个错误的规模。如果只是偶发一次,风险等级就没那么高;如果频繁发生,就必须升级处理。
我用脚本把最近 30 天的schedule_generate日志全部捞出来,对natural_lang字段做了一次规则匹配,看看有多少条描述包含“7 点到 7 点”“全天执行”“时段矛盾”之类的异常文本。统计结果让我有点意外:大约 6% 的描述存在区间矛盾。也就是说,每 17 个定时任务里,就有 1 个的模型自我描述是错的。这数量已经足够大到影响告警判断了。
同时我抽查了几条不同任务的日志,发现矛盾不只在“7-17”这种区间上。有的 0 点到 6 点被描述成“0 点到 0 点”,有的“每 30 分钟”被理解成“30 点”。模型的犯错模式很随机,但都集中在时间区间的语义解析上。这个比例说明,不能把这当成小概率幻觉,而必须通过架构手段去兜底。
4.2 核心修复:不让模型自己生成“人话描述”
问题找到了,怎么修?我的方案很朴素:凡是能从结构化数据里推导出来的文本,绝不让模型生成。
cron 表达式本身是结构化数据,它对应的人类可读描述完全可以由代码来生成,根本不需要模型开口。我引入了一个本地的 cron 解析库,把表达式转换为标准的时间序列,再基于下一次和第二次执行时间生成确定性的描述文本。例如0 7-17 * * *,程序可以直接算出下一次执行是 07:00,第二次是 08:00,然后生成“从 07:00 开始,每隔 1 小时执行一次,至 17:00 结束”。
这样一来,模型转述的环节就整个取消了。Agent 仍然负责理解用户意图并生成 cron,但生成之后的所有解释文本都由代码接管。用户看到的、其他模块读取的,都是经过本地校验后生成的结果,而不是模型的自由发挥。
修复逻辑用伪代码表示大概是这样:
from crontab import CronTab def format_schedule(cron_text: str) -> str: schedule = CronTab(cron_text) next_one = schedule.next(default_utc=False) next_two = schedule.next(default_utc=False, delta=False) interval_minutes = round(next_two - next_one) # 计算起始结束时间、执行次数,生成固定格式描述 return f"从 {start_time} 开始,每隔 {interval_minutes} 分钟执行一次,共 {count} 次"这个改动有一个额外的好处:描述文本的格式高度统一,后续就算要做定时任务状态展示、用户通知、抑或是任务审计,都能直接用同一套模板。
4.3 给 Agent 加一道“护栏”:输出校验
除了不让模型生成描述文本,我还给 cron 生成环节加了一道护栏:Agent 生成 cron 后,必须通过一个本地校验器做合法性检查,校验不通过就重新让模型生成一次。这个校验器做三件事。
第一,格式校验。用标准解析库解析 cron,无法解析就直接丢弃,要求模型重新输出。
第二,合理性校验。计算未来 5 次执行时间,如果执行时间分布异常,比如下一次执行时间和第二次执行时间相同、或者两次执行间隔超过 24 小时但用户意图明显是频繁任务,就要告警并重新解析。
第三,意图一致性校验。这一步比较取巧:把用户原始输入里的时间关键词提取出来,再和 cron 表达式中解析出的时间区间做对比。例如用户输入里有“7 点到 17 点”,而 cron 解析出的执行时间窗口只有 1 小时,那就说明模型理解偏了。
用大白话讲,这是给模型配了一个“监工”。模型可以犯错,但监工必须在错误传导到下游之前把它拦下来。
5. 免费模型选型与 Agent 架构里的防幻觉设计
5.1 什么时候适合用免费模型,什么时候不适合
经历了这次事件之后,我对免费模型的态度变得更加明确:它是工具,不是万能灵药,关键要看用在什么环节。
你要是让免费模型帮你做摘要、分类、抽取关键词、生成文案初稿,那它完全够用,性价比极高。但你要是让它做精确的、格式化的、计算型的工作,比如生成 cron、编写正则表达式、做单位换算、处理多步算术,那就得慎之又慎,必须配工具和校验。
在自养 Agent 的项目里,我的经验是:免费模型适合做“意图识别”和“内容生成”这类开放型任务,不适合做“格式转换”和“数值计算”这类闭合型任务。如果任务本身是闭合的,比如从自然语言生成 cron 表达式,我建议把模型的职责缩小到“抽取关键信息”,而不是让它一步到位生成完整结果。让模型输出一个结构化的 JSON,比如{"hour_start": 7, "hour_end": 17, "interval": "hourly"},然后用代码去拼 cron。模型只负责理解语义,不负责处理格式细节,这样错误率会大幅下降。
5.2 让工具接管确定性工作,模型少动脑
这件事给我最大的启发,就是 Agent 架构里要分清“谁该用脑,谁该用规则”。模型的强项是理解模糊语义,工具的强项是执行精确计算。正确的做法是让模型只做第一步,剩下的交给工具。
拿这次 cron 事件举例。最优的链路不是“用户自然语言 → 模型生成 cron → 调度器”,而是“用户自然语言 → 模型抽取意图参数 → 代码组装 cron → 本地解析库校验 → 调度器执行”。模型只输出一个带有时间参数的 JSON,所有格式组装都在代码层完成。这样就算模型把“17”理解成“1-7”,代码层也能通过类型检查和范围检查把错误拦在门外。
我在这个项目里最终实现了这样一条链路:
用户输入 -> Agent 抽取意图(JSON) -> 意图校验器 -> cron 生成器 -> cron 解析器 -> 调度器意图校验器专门检查 JSON 里的时间、频率参数是否符合基本常识,比如开始时间必须在 0 到 23 之间、结束时间必须大于开始时间、执行间隔不能为负数。cron 生成器则是一个纯函数,把合法参数映射成标准 cron 表达式。这一套流程跑下来,模型犯错的概率被压缩到了最小,而且每一环的错误都有人盯着。
5.3 多做一步“一致性校验”,效果胜过优化一万句 prompt
很多人在 Agent 开发里喜欢花大力气调 prompt,试图让模型别犯错。我不反对调 prompt,但说句实话,prompt 只能降低犯错概率,不能保证不犯错。真正可靠的防线,是校验。
我建议在 Agent 的输出层统一加一个校验组件,无论模型输出的是 cron、正则、SQL、还是普通 JSON,都先用代码解析一遍,解析失败就直接进入重试流程。重试时可以换一个 prompt 模板,也可以换一个更强的模型,前提是后台要记录当前用的哪个模型、失败了几次,方便后期做成本分析和模型选型。
一致性校验还有一个妙用:它能帮你发现模型输出和实际执行之间的偏差。比如模型说“计划 7 点到 17 点运行”,但实际 cron 解析出来的运行窗口是 7 点到 8 点,这种矛盾经过校验就能立刻被标记出来。长期收集这些标记,你对自己用的模型在哪些任务上容易翻车会有一个非常直观的认知。
6. 避坑清单与排查技巧速查
6.1 cron 场景的高频坑位一览
把这次踩坑经历做一次系统性总结,我整理了一份 cron 相关的避坑清单,做 Agent 定时任务时建议直接对照排查。
| 坑位 | 典型表现 | 补救方法 |
|---|---|---|
| 模型对时间区间理解出错 | 7-17 被说成 7 点到 7 点 | 模型只输出参数 JSON,cron 由代码生成 |
| 时区不一致 | 服务器 UTC 比本地早 8 小时 | 日志与 cron 统一记录时区,调度器显式指定时区 |
| 周字段与日字段冲突 | 指定了周几又指定了几号,结果一直不触发 | 生成后解析下一次执行时间,做运行时验证 |
| cron 特殊字符被 shell 展开 | 未加引号导致*被解释成通配符 | 拼 cron 命令时统一走转义函数 |
| 描述文本与执行计划不一致 | 用户看到 7-17 点,实际只跑了一次 | 描述文本统一由解析器生成,模型不参与 |
这些坑单拎出来每一个都不算复杂,但叠加在一起,就会让 Agent 的定时任务变得像薛定谔的猫:你永远不知道它到底跑没跑、按什么跑的。
6.2 三个实用小技巧,立省排查时间
第一,给所有 Agent 外呼日志加上请求 ID 和模型版本。这个习惯能让你在面对“为什么同一个任务有时正常有时抽风”的问题时不至于从零开始。只要日志里有模型版本,你就可以按版本做聚合统计,快速发现是不是切模型之后才开始出错的。
第二,prompt 里加少量样例,尤其是目标格式的样例。如果确实需要模型输出 cron,我给每个 prompt 里至少放两个正例和两个反例,让它知道“7-17”代表一个区间而不是两个孤立时间点。正反例混合的写法比纯指令描述效果好很多。
第三,把“描述性输出”和“执行性输出”分开记录。执行性输出走结构化字段,描述性输出走文本字段,两者之间通过同一个request_id关联。这样就算描述性输出出了问题,也影响不到执行性输出的链路,排查时还能直接对照核心字段。
6.3 当模型反复出错时,不要硬撑
如果做完上面这些优化,模型还是会在关键任务上反复出错,那就别硬扛了。我的建议是引入模型分级策略:普通任务用免费模型,关键格式任务用更强的模型或者干脆不用模型。
这里“不用模型”不是玩笑。有些任务真的不需要模型参与。用户的需求如果已经通过表单或者结构化指令表达清楚了,就没必要让模型再转一道。Agent 的价值在于处理不可预见的开放式需求,而不是给所有需求都套一层模型封装。把模型集中在它最擅长的环节上,既省钱又省心,出问题的概率也小得多。
6.4 日志里找不到问题怎么办
最后再说一个排查技巧。有时候日志确实记录了异常,但你 grep 了半天也没找到关键内容,问题可能出在日志本身的粒度上。建议先确认三个点:日志有没有记录模型原始输出;日志有没有记录工具入参和出参;日志有没有记录错误堆栈。这三个点都齐了,Agent 问题基本都能定位到具体模块。
如果日志里连异常都没有,那就考虑是不是日志自己把异常吞了。有些封装代码习惯性地try except后不打印异常信息,或者把错误消息截断成几百字符,这样关键信息就丢了。我的习惯是异常对象必须完整记录,包括模型返回的原始响应体和 HTTP 状态码,尤其是免费模型接口经常返回一些奇怪的状态码,不记录完整响应根本没法分析。
回到这次“7 点到 7 点”的乌龙,我最后的处理方案就一句话:让模型回到它该在的位置,把确定性的事交给确定性工具。Agent 不是越聪明越好,而是分工越明确越好。免费模型能帮我们把成本降下来,但前提是你得用对地方,并且给所有输出都装上校验和日志,让每一次幻觉都无所遁形。
现在我做 Agent 任务调度的习惯是:每一条 cron 生成之后,除了看最终执行结果,还会额外核对一下“模型意图参数”和“实际执行计划”的对应关系。说实话,模型在大多数时候是靠谱的,但偶尔那句“7 点到 7 点”会提醒我,把系统的安全边界建立在验证而非信任之上,才是做 Agent 最稳妥的姿态。