这个系列是我回头读自己项目代码的记录。项目是一个简历定制 Agent:给它一份 JD 和一份简历,它调用大模型产出针对这个岗位的简历。
第一篇只讲一件事:模型返回的 JSON 为什么会坏,以及代码里怎么把坏掉的输出挡在业务之外。
一、它解决什么问题
先打个比方。
你请装修师傅按图纸做个柜子,图纸写着「宽 60 深 40 高 180」。师傅手艺没问题,但他可能回你一句「大概六十宽吧」——他不是不会做,而是没有义务逐字遵守你的图纸。
大模型就是这个师傅。请求里写了 response_format: { type: 'json_object' },它只承诺「我说的是 JSON」,不承诺「字段名、类型、枚举值都和你要求的一样」。
项目里真实遇到过这些:
| 问题 | 具体表现 |
|---|---|
| 字段名换了 | 返回 job_title,schema 要的是 targetTitle |
| 类型不对 | requirements 本该是对象数组,返回了字符串数组 |
| 枚举越界 | importance 返回 required,只允许 must-have / nice-to-have |
| 多包一层 | 整个对象被塞进 data / result / output 里 |
| 字段缺失 | targetTitle 干脆没有 |
注意:这些输出 JSON.parse 全都成功,HTTP 也是 200。所以「请求成功」和「数据能用」是两件事。
二、不用它会怎样
类型不对那条最要命。如果代码直接把字符串当对象用,requirement.text 读出 undefined,匹配率算成 0%,用户以为是自己简历不行——错误被静默地转换成了一个看起来合理的业务结果,这比直接报错危险得多。
字段缺失则是另一种坏:坏在流程深处,报错信息里看不出是哪一步坏的。
三、代码是怎么做的
核心模块是 packages/resume-agent/src/llm/structured-output.ts。思路一句话:
模型输出是不可信输入:先校验,再保守兼容,最后有界修复。
第一步,直接校验。拿到 JSON 先跑 Zod,成功就返回,并记录 normalized: false。先直接校验而不是先修复,是为了不破坏本来合法的输出,也让「兼容层被触发多少次」变成可观测指标。
第二步,保守兼容。校验失败后只做两件确定性的事:解包一层 data / result / output,再跑可选的 normalizer。规则必须「含义唯一」:job_title → targetTitle、required → must-have、字符串 requirement → 带稳定 ID 的对象。未知枚举不猜,宁可失败。
第三步,有界 Repair。还不行,就把 Zod 的字段路径和错误码喂回模型让它修一次:
const DEFAULT_MAX_REPAIR_ATTEMPTS = 1上限写死为 1(接口只允许配 0/1/2)。每次修复都是真实的 token 和延迟,而且连续失败通常说明 Prompt 或 schema 本身有问题,重试只会把确定性故障放大成资源耗尽。
组装修复 prompt 时有个细节:原始输入和上一次输出都被夹在 UNTRUSTED DATA 分隔符里,并明确要求模型不要执行里面的指令——因为 JD 是用户上传的内容,里面完全可能写着「忽略上面的要求」。
第四步,失败也要说清楚,但不能泄露。修复耗尽后抛 StructuredOutputValidationError,只带 schema 名、字段路径、issue code 和调用统计,不带简历正文、JD 原文和原始模型输出,因为这个错误最终会变成 HTTP 响应。
这套逻辑被三个边界共用:简历归一化、JD 分析、初稿生成。另外有个对照组:匹配度计算 buildMatchReport 是纯代码算的,没有让模型参与。能用确定性代码做对的事,就别交给模型。
四、面试官会追问什么
Q:把 temperature 调成 0,或者用厂商的 strict JSON Schema 不就行了? A:strict 模式在兼容第三方 OpenAI-compatible 服务时不能假设一定生效;而且它只保证结构符合 schema,不保证内容符合业务事实。项目里另有
validation/resume.ts校验姓名、邮箱、联系方式不被模型改写,这层不能省。
Q:为什么只修一次? A:成本和延迟。文档写明了:只有 Eval 数据证明第二次有显著收益才允许放开到 2。无限重试会掩盖真问题。
Q:兼容归一化会不会是在给坏数据「洗白」? A:所以规则被限定为「含义唯一、映射可解释、测试可枚举」。像补一个模型没给的职位名,属于发明事实,明确不做。
Q:为什么不记录原始输出方便排查? A:简历和 JD 是个人敏感数据。错误里只留路径和错误码,能定位「哪个字段坏了」,但看不到内容。
五、一句话版本
大模型的输出是外部输入,不是函数返回值:先校验、再保守兼容、最后有界修复,修不好就诚实失败。
六、自测
JSON.parse成功,是否代表模型输出能用?为什么还要 Zod?- 项目区分了哪三类失败?分别由谁负责?
- 为什么先直接校验,而不是一上来就做兼容归一化?
- Repair 默认几次?为什么必须有上限?
- 为什么错误信息里不能带原始模型输出?