
接手启明英语的批改模块后我干的第一件事是把第 2 篇那套人格提示词接进业务然后立刻撞上了下一堵墙。模型的回答真的变规整了错在哪、为什么、怎么改三段清清楚楚老师看了都说好。但产品那边给我的需求单是这么写的批改结果要进错题本每个错误一条记录小程序端要渲染成卡片原文一行、改正一行、解释一行运营每周要按错误类型统计时态错几条、冠词错几条画柱状图。这三件事有一个共同前提程序得知道哪一段是 original、哪一段是 corrected、error_type 是哪个值。而模型给的是一段格式漂亮的散文字符串里什么都有就是没有字段。我第一版的处理方式很朴素正则。original[:10]抠原文“后面的内容re.findall抠类型时态”。上线第三天运营跟我说统计图不对——我查了半天日志才发现那天模型心情好把类型时态错误多写了俩字正则没兜住。更糟的是这类 bug 不报错。正则抠不到就给空字符串空字符串默默写进错题本等到老师打开小程序看到一张没有改正内容的卡片问题已经过去两天了。这篇就把这件事一次解决让模型按我们定义的结构回答拿回来直接是能.model_dump()入库的对象而不是一坨等着被正则肢解的文本。一、先扫盲为什么请输出 JSON这句话没用我的第一反应特别典型在 system prompt 里加一句请以 JSON 格式输出。加完之后失败率从天天错降到了偶尔错——对要进数据库的数据来说偶尔错等于天天错。因为这句话只表达了愿望没有表达规格。翻翻失败日志翻车姿势其实就那么几种没说字段叫什么。我要的是error_type模型给你type、错误类型甚至category全看它当天想起哪个词。前后缀寒暄。“好的这是批改结果” 打头结尾再补一句希望对你有帮助。整段不再是合法 JSONjson.loads直接抛异常。中文引号和尾逗号。模型偶尔把写成“或者在最后一个字段后面留个逗号。人眼看不出问题解析器一刀毙命。取值越界。我要 severity 是 1 到 3它给你 5还振振有词。字符串阶段你根本拦不住。真正的解法是把规格从希望变成契约先用代码定义数据长什么样再让框架把这份定义翻译给模型最后拿到回答时按同一份定义校验。落地方式有三种从糙到精方式做法问题提示词 json.loads自己抠在 prompt 里手写一段 JSON 示例回答回来自己解析说明书手写字段和约束全靠嘴说解析要自己兜底PydanticOutputParser框架从 Pydantic 类自动生成格式说明书塞进提示词回答回来按 schema 校验模型仍是自由文本偶尔夹带寒暄需要重试兜底with_structured_output()框架把 schema 转成工具function calling发给模型模型填参数失败率最低但要求模型支持工具调用第三种是首选第二种是备胎。我们工程里两条都实现了因为私有部署的小模型不支持 function calling 的场景真实存在——客户环境不归你挑。二、工程目录settings.py和llm.py与第 1 篇完全一致原样复用一个字没改。新增的部分按职责拆成六个文件04-结构化输出/ ├── src/ │ ├── settings.py # 复用第 1 篇 │ ├── llm.py # 复用第 1 篇 │ ├── schema.py # 数据契约ErrorItem / Correction │ ├── parser.py # 两条通路 解析返回统一的结果类型 │ ├── validate.py # 业务校验pydantic 管不了的那几条 │ ├── repair.py # 解析失败重试闭环 │ ├── store.py # JSONL 错题本 按类型统计 │ └── fakes.py # 离线假模型支持工具通路 ├── tests/ # 39 个用例离线全绿 ├── demo.py └── pytest.ini一句话概括数据流向模型 → parser结构对不对→ validate内容对不对→ store入库repair 横在 parser 失败的地方兜底。三、schema.py契约先于代码为什么第一个文件是 schema因为这一篇的核心动作是把四方拉到同一份定义前模型按它输出后端按它入库小程序按它渲染周报按它统计。schema 单独成文件谁都能 import改一处四处生效。字段名要是散落在各个函数里改一个字段等于四处漏风。# 错误类型的候选集。用 Literal 而不是 str等于把能不能自创类型# 的决定权从模型手里拿回来越界就报错而不是悄悄写进数据库。ERROR_TYPESLiteral[时态,主谓一致,冠词,介词,拼写,其他]classErrorItem(BaseModel):一条错误原文片段、改正片段、类型、解释、严重程度。original:strField(description学生原句里出错的那一小段不要整句照抄)corrected:strField(description与 original 逐词对应的正确写法必须真的改了不能原样照抄)error_type:ERROR_TYPESField(description只能从「时态/主谓一致/冠词/介词/拼写/其他」里挑一个不许自创)explanation:strField(description面向初中生的中文解释一句话说清为什么错)severity:intField(geSEVERITY_MIN,leSEVERITY_MAX,description严重程度1轻微不影响理解2影响理解3完全看不懂,)这里我最想强调的是Field(description...)。它不是写给你自己的注释——pydantic 会把它转进 JSON Schema跟着请求一起发给模型。字段名叫original模型不知道你要它抄整句还是抄出错的小段description 里写一句只抄出错的那一小段输出质量立刻不一样。这是我全篇性价比最高的一行字改文案就能调输出不用动任何逻辑。Correction是外层classCorrection(BaseModel):一次批改的完整结果错误清单 总评 要不要重写。items:list[ErrorItem]Field(default_factorylist,# 模型在没错误时经常直接省略这个字段description错误清单一个错一条句子没毛病就给空数组 [],)overall:strField(description一句话总评直接说给学生听)needs_rewrite:boolField(description整句是否需要重写items 为空时必须给 false)items给默认值不是偷懒是防御模型对零错误的处理出奇地不稳定有时给[]有时干脆不写这个字段。不给默认值后者直接 ValidationError重试一次就多花一次钱。四、parser.py两条通路一个出口先说清楚一个容易误判的点with_structured_output()失败了不会抛异常——模型跑题写散文、压根没调工具时LangChain 会安静地返回None。不做拦截None会一路流到错题本。所以两条通路我都包了一层统一返回一个结果对象dataclass(frozenTrue)classParseOutcome:一次解析的完整结果成了没、模型原话、错在哪。correction:Correction|NoneNoneraw:str# 模型原话重试时要原样贴回给它看error:str|NoneNone# 失败原因压成一句话propertydefok(self)-bool:returnself.correctionisnotNone通路一工具通路deftry_parse_chain(llm,messages)-ParseOutcome:# include_rawTrue 是为了失败时不丢证据# raw 里有模型原话parsing_error 里有错因都是重试时要贴回去的东西structuredllm.with_structured_output(Correction,include_rawTrue)resultstructured.invoke(messages)ifresult[parsed]isNoneandresult[parsing_error]isNone:# 模型没调工具跑题写散文时 LangChain 不报错静默给 Noneerror模型没有按 Correction 的结构回答没调用工具returnParseOutcome(correctionresult[parsed],raw_snapshot(result[raw]),errorerror)通路二提示词通路用PydanticOutputParser_PROMPT_PARSERPydanticOutputParser(pydantic_objectCorrection)defbuild_format_prompt()-ChatPromptTemplate:returnChatPromptTemplate.from_messages([(system,SYSTEM_PROMPT),(human,请批改这个句子{sentence}\n\n{format_instructions}),# format_instructions 里全是花括号写进模板正文会被当成占位符炸掉# 必须用 .partial() 预填]).partial(format_instructions_PROMPT_PARSER.get_format_instructions())两条通路的差别值得说透工具通路里 schema 变成了 function calling 的参数定义模型是在填表字段名和取值范围由接口层面约束提示词通路里 schema 被翻译成一段自然语言说明书get_format_instructions()生成的那 1700 多字符模型是在读说明书写作文然后我们拿着同一份 schema 验收。前者失败率低后者哪都能用。对外的一次性入口各一个失败抛自己的异常defparse_with_chain(llm,sentence)-Correction:# 工具通路defparse_with_prompt(llm,sentence)-Correction:# 提示词通路五、validate.py过了 pydantic 不等于能入库写这层的时候我想明白了一件事pydantic 管的叫类型合法severity 是不是 1~3、error_type 在不在候选集里但业务合法它管不了——改了等于没改、标了拼写却改得面目全非、说要重写却一条错误都没挑出来。这些规则只有做这个产品的人知道只能自己写代码查。MAX_SPELL_EDIT_DISTANCE3# 拼写错嘛差一两个字母差十个的那叫改写defvalidate_item(item:ErrorItem,index:int)-list[str]:problems[]# 规则一改了等于没改。模型偷懒时会把原句原样抄一遍充数这条最常见ifitem.original.strip()anditem.original.strip()item.corrected.strip():problems.append(f第{index}条original 和 corrected 完全一样等于没改)# 规则二标成拼写两个片段却差得很远多半是类型标错了ifitem.error_type拼写anditem.originalanditem.corrected:dedit_distance(item.original.lower(),item.corrected.lower())ifdMAX_SPELL_EDIT_DISTANCE:problems.append(f第{index}条标成了拼写错误但相差{d}个字符)returnproblemsdefvalidate_correction(correction:Correction)-list[str]:problems[]forindex,iteminenumerate(correction.items,start1):problems.extend(validate_item(item,index))# 规则三一句错误都没挑出来needs_rewrite 却是 true自相矛盾ifnotcorrection.itemsandcorrection.needs_rewrite:problems.append(一句错误都没挑出来needs_rewrite 却是 true自相矛盾)returnproblems返回问题列表而不是抛异常是刻意的列表可以直接拼进重试提示词告诉模型你哪儿错了也可以原样进日志给运营看。编辑距离不引第三方库十几行动态规划写清楚比多一条依赖好维护。这层是最后一道闸门过了它才能进数据库。六、repair.py重试不是再说一遍请输出 JSON解析失败怎么补救大多数人的第一版是在外面套个while True失败就重发一遍原请求。我也写过然后发现模型会非常配合地把同一个错误再犯一遍——你不说它错哪了它凭什么改正确的闭环是把错因 上次输出一起贴回去RETRY_TEMPLATE上一次的输出没有通过校验问题如下 {problems} 你上一次输出的是 {raw} 请重新来一遍。要求 1. 补齐缺失字段字段值必须在允许范围内比如 severity 只能是 1、2、3 2. 不要输出任何解释性前后缀也不要用 Markdown 代码块包裹 3. 如果你认为的确没有错误就给空的 items并把 needs_rewrite 设为 false defanalyze(llm,sentence,*,use_chainTrue,max_attemptsDEFAULT_MAX_ATTEMPTS):messages(TOOL_PROMPTifuse_chainelsebuild_format_prompt()).format_messages(sentencesentence)attempts[]forindexinrange(1,max_attempts1):outcometry_parse_chain(llm,messages)ifuse_chainelsetry_parse_prompt(llm,messages)ifoutcome.ok:# 结构对了不代表内容对业务校验在这一道闸门后面等着problemsvalidate_correction(outcome.correction)ifnotproblems:returnRepairOutcome(correctionoutcome.correction,attemptsattempts)error_text.join(problems)else:error_textoutcome.error attempts.append(Attempt(indexindex,errorerror_text,rawoutcome.raw))ifindexmax_attempts:# 两个都要给只说错在哪模型不知道自己写了什么# 只贴上次的它会当成新句子重新批改一遍messagesmessages[_retry_message(error_text,outcome.raw)]returnRepairOutcome(correctionNone,attemptsattempts,problems[error_text])注意业务校验失败也走同一个闭环——结构合法的脏数据恰恰是最危险的重试是它的唯一出口。为什么最多 3 次首次 2 次重试就认输不无限重试三个理由。收益断崖式下跌第一次失败通常是模型漏看约束贴回去马上改对第三次还失败往往是这个句子本身跟 schema 打架再试也是同一个结局。成本线性上涨一次重试就是一次完整的输入加输出计费批改这种短任务重试两次的钱接近正常批改的三倍真要省钱该去改 description 而不是堆次数。掩盖真问题如果某类句子总是要重试才成功那不是重试的功劳是 schema 写得不好——attempts留痕就是用来发现这件事的重试率一高回头改Field(description)。认输时返回None加问题列表绝不含糊。宁可让上层走转人工也不静默塞一条脏数据进错题本。七、store.pyJSONL 落地defappend_record(correction:Correction,path:PathDEFAULT_PATH)-None:# 一行一条中文不转义运营用编辑器打开就能直接看withPath(path).open(a,encodingutf-8)ashandle:handle.write(json.dumps(correction.model_dump(),ensure_asciiFalse))handle.write(\n)defcount_by_type(records:list[Correction])-dict[str,int]:# 固定六个 key没出现过的类型补 0画图的时候少一个 key 就得写 if 分支counts{error_type:0forerror_typeinERROR_TYPE_ORDER}forcorrectioninrecords:foritemincorrection.items:counts[item.error_type]counts.get(item.error_type,0)1returncounts为什么是 JSONL 不是一个大 JSON 数组一行一条写只管 append不用整文件读出来改完再写回去wc -l就知道批改次数流式聚合也不撑内存。哪天要换数据库把append_record里两行换成 INSERT上层一行不动——这就是先把 Correction 定成契约的回报。还有一个不起眼但必须有的角色是fakes.pyLangChain 自带的假模型没有实现bind_tools而with_structured_output()开头就检查这个能力所以离线模式得自己写一个二十行的假模型——绑了工具就把预置 JSON 伪装成 tool_calls没绑就原样吐文本。没有它两条通路都没法离线测试。八、跑起来真实输出../_venv/bin/python demo.py。说明本机没配 API Keydemo 全程走离线假模型三条链路的模型回答都是预置的脚本包括那条解析失败——这是刻意的演示目的是看清链路每一步的形态不是展示模型能力。──────────────────────────────────────────────────────────────────── 第 4 篇演示 · 结构化输出全部离线不需要 API Key ──────────────────────────────────────────────────────────────────── 0. 格式说明书由 schema 自动生成节选 长度 1766 字符 说明书里是否写了 severity 的取值范围 True 1. 正常链路 · 工具通路 with_structured_output 学生句子I go to the park yesterday and eat a apple. 返回类型Correction不是字符串是一个对象 总评句子能看懂主要卡在时态和冠词上先记住过去时间要用过去式。 是否需要重写True 错误条数2 [1] 时态 · 严重程度 2 原文I go to the park 改正I went to the park 解释yesterday 是过去时间动词要用过去式 [2] 冠词 · 严重程度 1 原文a apple 改正an apple 解释apple 以元音开头不定冠词要用 an 业务校验 通过可以入库 2. 业务校验 · 结构全对内容却是脏的 pydantic 校验通过返回 Correction类型完全合法 业务校验发现 2 个问题 ✗ 第 1 条original 和 corrected 完全一样Yesterday I go there等于没改 ✗ 第 2 条标成了拼写错误但两个片段相差 49 个字符超过阈值 3类型标错了还是改太多了 处理方式拦下不入库转人工或触发重试 3. 重试闭环 · 提示词通路第一次解析失败 学生句子She dont like the coffee what I made. 重试次数1上限之内 第 1 次失败原因Invalid json output: 好的这是批改结果希望能帮到你: … 最终结果成功 [1] 主谓一致 · 严重程度 2 原文She dont like 改正She doesnt like [2] 其他 · 严重程度 2 原文the coffee what I made 改正the coffee that I made 4. 入库 · JSONL 错题本 文件错题本.jsonl 行数2一行一条 按错误类型统计 时态 1 █ 主谓一致 1 █ 冠词 1 █ 介词 0 拼写 0 其他 1 █ 错误总数4 批改次数2第 2 段是我最想让你盯着的pydantic 说它完全合法业务校验拦得干干净净。这就是类型合法和业务合法的区别两层缺一不可。九、真实 pytest 输出$ ../_venv/bin/python -m pytest --captureno -p no:cacheprovider -v tests/test_parser.py::test_chain_path_returns_correction_object PASSED tests/test_parser.py::test_prompt_path_returns_correction_object PASSED tests/test_parser.py::test_two_paths_produce_same_result PASSED tests/test_parser.py::test_format_instructions_contain_field_names PASSED tests/test_parser.py::test_format_prompt_carries_instructions PASSED tests/test_parser.py::test_prompt_path_fails_on_prefix_chatter PASSED tests/test_parser.py::test_chain_path_reports_runaway_model PASSED tests/test_parser.py::test_chain_path_reports_field_error PASSED tests/test_parser.py::test_parse_with_chain_raises_on_failure PASSED tests/test_repair.py::test_first_try_success_does_not_retry PASSED tests/test_repair.py::test_retry_after_parse_failure PASSED tests/test_repair.py::test_retry_message_carries_reason_and_previous_output PASSED tests/test_repair.py::test_gives_up_after_max_attempts PASSED tests/test_repair.py::test_business_validation_also_triggers_retry PASSED tests/test_repair.py::test_max_attempts_one_disables_retry PASSED tests/test_repair.py::test_prompt_path_also_repairs PASSED tests/test_schema.py::test_valid_item_can_be_built PASSED tests/test_schema.py::test_severity_above_max_raises PASSED tests/test_schema.py::test_severity_below_min_raises PASSED tests/test_schema.py::test_unknown_error_type_raises PASSED tests/test_schema.py::test_descriptions_land_in_json_schema PASSED tests/test_schema.py::test_items_default_to_empty_list PASSED tests/test_schema.py::test_missing_overall_raises PASSED tests/test_store.py::test_append_then_load_roundtrip PASSED tests/test_store.py::test_one_record_per_line PASSED tests/test_store.py::test_chinese_is_not_escaped PASSED tests/test_store.py::test_load_missing_file_returns_empty PASSED tests/test_store.py::test_count_by_type_fills_every_type PASSED tests/test_store.py::test_count_by_type_is_correct PASSED tests/test_store.py::test_total_errors_counts_items_not_records PASSED tests/test_validate.py::test_clean_correction_has_no_problems PASSED tests/test_validate.py::test_identical_original_and_corrected_is_flagged PASSED tests/test_validate.py::test_blank_explanation_is_flagged PASSED tests/test_validate.py::test_blank_original_is_flagged PASSED tests/test_validate.py::test_spelling_rule_cuts_both_ways PASSED tests/test_validate.py::test_empty_items_rewrite_flag_must_be_false PASSED tests/test_validate.py::test_rewrite_with_only_minor_errors_is_flagged PASSED tests/test_validate.py::test_problem_message_points_to_the_right_item PASSED tests/test_validate.py::test_edit_distance_basics PASSED 39 passed in 0.42s 39 个用例全绿全程离线。两条通路都断言返回的是Correction对象且结果一致test_descriptions_land_in_json_schema盯着 description 真的进了 JSON Schema——如果哪天有人把 description 删了当成注释清理掉这条测试会当场跳起来。十、踩坑清单工具通路静默返回None。模型跑题写了段散文、没调工具时LangChain 不报错。try_parse_chain里那段parsed is None and parsing_error is None的判断就是为它写的删了它None就直通数据库。格式说明书写进模板正文会炸。get_format_instructions()生成的文本里全是花括号直接拼进ChatPromptTemplate的正文会被当成占位符运行时 KeyError。必须走.partial()。压缩 ValidationError 再进提示词。pydantic 的报错默认带一大段文档链接的堆栈原样贴回去又贵又吵。只拎出哪个字段 为什么塞给模型效果一样token 少一个量级。重试消息只贴错因不贴原文模型会当成新句子重批。两个都要给缺一不可。假模型也必须实现bind_tools。自带的FakeListChatModel过不了with_structured_output的能力检查离线测试直接NotImplementedError这是自己写fakes.py的全部原因。别把校验规则全塞给 pydantic 的 validator。业务规则和类型规则分开类型规则在 schema业务规则在validate.py——前者模型必须服从后者模型只能尽量服从。pytest 报No usable temporary directory本机安全策略限制临时文件所致加--captureno -p no:cacheprovider即可与代码无关。十一、本篇收尾现在批改结果从一段等着被正则肢解的散文变成了一个过了两道闸门的 Correction 对象进错题本、渲染卡片、按类型统计三件事拿到的都是带字段的干净数据。格式从愿望变成了契约而且这份契约有 39 条测试看着。下一篇解决另一个大坑学生问虚拟语气怎么用助教答得头头是道问教材第 3 单元讲了什么就开始瞎编。我们会引入 RAG——先检索再回答让模型只根据喂给它的教材内容说话把瞎编从玄学变成可以度量、可以优化的问题。最后一句模型不是不会写 JSON是你没告诉它交卷的答题卡长什么样——规格写在代码里才叫契约写在提示词里只能算许愿。