
1. 从一行封装说起为什么结构化输出值得单独拎出来讲大模型应用做到一定深度之后你会发现一个很尴尬的现实模型能说会道但它的输出是“散文”而你的下游系统要的是“表格”。你让它从一段用户描述里抽取订单信息它回你一段热情洋溢的总结你让它判断意图分类它给你加一句“根据您的描述我认为这属于……”。人看着没问题代码解析起来全是坑。withStructuredOutput这个封装的价值就是把这层“散文转表格”的脏活收敛到一个函数调用里。它的核心逻辑并不神秘底层走的是 Tool Call工具调用机制把目标数据结构定义成一个“工具”的参数 schema模型在生成时不再自由发挥而是按照 schema 填充参数框架再把这段参数解析成对象返回给你。一行封装省掉的是手写 prompt 约束、正则清洗、JSON 修复、重试兜底这一整套流程。这篇内容适合谁看如果你正在做基于 LLM 的信息抽取、意图识别、表单填充、Agent 决策这类需要“确定性输出”的功能或者你已经用过结构化输出但被流式场景和落库环节卡住过那这篇就是写给你的。我会从设计思路讲到 Tool Call 的底层原理再讲到流式输出怎么处理半截 JSON最后落到 MySQL 的建表、批量写入和常见故障排查。全程按我实际项目里的做法来能抄的地方直接抄。需要先说明一点不同语言生态里这个能力的封装名字不一样Python 侧常见的是 LangChain 系的with_structured_outputJava 侧有 Spring AI 的.entity()之类。本文用withStructuredOutput这个偏通用的叫法来统称这类能力具体 API 名以你用的框架为准但底层机制和踩坑点是共通的。2. 结构化输出的整体设计与方案选型2.1 三种主流实现路径的取舍在withStructuredOutput这类封装普及之前大家实现结构化输出基本是三条路我把它们的实际表现列出来对比一下。方案实现方式稳定性流式友好度维护成本Prompt 约束 正则解析在提示词里写“只返回 JSON”低差高JSON Mode模型侧强制输出合法 JSON中中中Tool Call 结构化输出schema 定义参数模型填参高好低Prompt 约束这条路我早期用得最多也是最容易翻车的。你写“请只返回 JSON不要有任何其他内容”模型大部分时候听话但总有那么几次它会加个“好的以下是结果”或者把 JSON 包在 markdown 代码块里。你得写正则去剥剥完还得处理尾逗号、单引号、转义字符。这套清洗逻辑写着写着就变成一个小型解析器维护起来非常痛苦。JSON Mode 好一些模型侧保证了输出是合法 JSON但它不保证字段名和类型符合你的预期。你想要的order_id它可能给你orderId你想要的数字它给你字符串。而且 JSON Mode 对流式的支持比较别扭因为 JSON 必须完整才能解析。Tool Call 这条路是目前最稳的。原理是把你的目标结构定义成一个函数的参数 schema模型在生成时进入“填参数”模式字段名、类型、必填项都由 schema 约束。框架拿到 tool call 的 arguments 之后直接反序列化你拿到的是强类型对象。这也是withStructuredOutput底层干的事。2.2 为什么选 Tool Call 而不是自己写解析有人会问Tool Call 不也是模型输出一段 JSON 字符串吗跟我自己让它输出 JSON 有什么区别区别在于约束的强度。当你用 prompt 说“返回 JSON”时这是软约束模型可以违反。当你把结构注册成 tool 时模型在训练阶段就见过大量“调用工具要填参数”的样本它对这种模式的遵循度远高于自由文本里的 JSON 要求。更关键的是很多推理服务在采样阶段会对 tool call 的输出做语法层面的约束比如用有限状态机限制 token 只能生成符合 schema 的内容这就从“请求它别错”变成了“它错不了”。我实测过一个抽取任务同样的模型prompt 约束方案的字段准确率大概在 92% 左右换成 Tool Call 之后稳定在 99% 以上剩下的 1% 基本是模型对内容本身理解错了而不是格式错了。这个差距在批量处理场景里会被放大因为 8% 的失败率意味着你每处理一千条就要人工兜底八十条。2.3 封装层要解决的核心问题withStructuredOutput这层封装本质上要解决四件事理解了这四件事你自己也能手写一个。第一是 schema 到 tool 定义的转换。你给一个类或者一个 Pydantic 模型它要能自动生成对应的 JSON Schema包括字段名、类型、描述、必填项、枚举值。字段描述特别重要它是给模型看的提示写得好能显著提升抽取准确率。第二是调用与解析。发起请求时带上 tool 定义拿到响应后从 tool_calls 里取出 arguments反序列化成对象。如果模型没调用工具而是直接回了文本要有兜底策略。第三是重试与纠错。模型偶尔会漏字段或者类型填错封装层要能识别并触发一次带错误信息的重试而不是直接把脏数据抛给业务层。第四是流式适配。这是最容易被忽略的一环也是本文后半段的重点。流式场景下 tool call 的 arguments 是分片到达的你得处理“半截 JSON”的问题。3. Tool Call 结构化输出的核心细节与实操要点3.1 Schema 设计字段描述比字段名更重要很多人设计 schema 时只关心字段名和类型忽略了 description。实际上模型是靠着 description 来理解“这个字段该填什么”的。我举个真实例子。假设你要抽取商品信息字段叫price。如果你只写类型是 number模型遇到“大概一百来块”这种描述时可能直接填 100也可能因为不确定而漏填。但如果你把描述写成“商品的实际售价单位为元如果用户只给了价格区间则取区间下限”模型的行为就明确多了。from pydantic import BaseModel, Field from typing import Optional, Literal class ProductInfo(BaseModel): 从用户描述中抽取的商品信息 name: str Field(description商品名称去掉修饰词保留核心品类词) price: Optional[float] Field( defaultNone, description商品售价单位元。若为价格区间取下限未提及则留空 ) category: Literal[数码, 服饰, 食品, 家居, 其他] Field( description商品所属大类只能从给定选项中选 ) tags: list[str] Field( default_factorylist, description商品特征标签最多5个每个不超过4个字 )这里有几个细节值得说。用Literal做枚举约束比在描述里写“只能是A或B”要可靠得多因为枚举会直接体现在 schema 里模型填错的可能性极低。用Optional加默认值来处理可能缺失的字段避免模型为了填满而编造。tags用列表并限制数量防止模型无限发挥。注意字段描述不要写得太长超过两句话模型反而容易抓不住重点。把最关键的约束放在描述的第一句。3.2 嵌套结构与展平的权衡实际业务里数据结构往往有嵌套比如订单里有用户信息、有商品列表、有地址。嵌套 schema 模型是能处理的但嵌套层级越深出错概率越高。我的经验是超过两层的嵌套就要考虑展平。比如order.user.address.city这种三层结构模型在填的时候容易在中间某层漏字段。你可以把它展平成user_city、user_street这样的平铺字段抽取完在代码里再组装回嵌套结构。多写几行组装代码换来的是准确率的提升这笔账划算。如果确实需要嵌套比如商品列表这种数组结构那就在描述里明确说明“这是一个数组每个元素包含以下字段”并且给数组长度一个上限。模型对数组的处理能力比嵌套对象要强因为数组的每一项结构一致模式更清晰。3.3 必填与可选的边界哪些字段该必填哪些该可选这个边界要想清楚。必填字段太多模型遇到信息不全的输入时会硬编必填字段太少你拿到的对象到处是空值业务层还得做一堆判空。我的做法是把“业务上绝对不能为空”的字段设为必填其余全部可选并给默认值。比如意图识别任务里intent字段必填因为下游路由依赖它但confidence这种辅助字段就可以可选模型不给就默认一个中间值。还有一个技巧是给必填字段加一个“未知”枚举值。比如分类任务与其让模型在信息不足时瞎猜一个类别不如给它一个unknown选项让它老实说不知道。这样你在业务层可以针对 unknown 走人工审核流程而不是被错误分类带偏。3.4 温度参数对结构化输出的影响温度temperature这个参数在结构化输出场景里的作用和自由生成不太一样。自由生成时温度高一点输出更多样但结构化输出要的是稳定温度应该往低了调。我一般把结构化抽取任务的温度设在 0 到 0.2 之间。温度设 0 时模型倾向于选概率最高的 token输出最稳定但偶尔会陷入某种固定模式。设 0.1 到 0.2 能在稳定性和灵活性之间取个平衡。超过 0.5 之后你会发现同一个输入多次调用字段值开始飘这对需要落库的数据来说是灾难。不过有个例外如果你用结构化输出做“生成多个候选”的任务比如让模型生成五条不同的营销文案并结构化返回那温度可以适当调高。判断标准很简单你要的是“唯一正确答案”还是“多样候选”前者低温后者高温。4. 流式输出下半截 JSON 的处理实战4.1 流式场景为什么棘手流式输出在聊天场景里体验很好用户能看到字一个个蹦出来。但结构化输出遇上流式就麻烦了因为 tool call 的 arguments 是分片传输的。模型生成 tool call 时arguments 是一个 JSON 字符串流式返回时它会被切成很多小块。你可能先收到{na再收到me: 苹再收到果, pri。这些片段单独看都不是合法 JSON你没法直接解析。更麻烦的是你无法预知下一个片段什么时候到也不知道总共会切多少片。这就引出一个核心问题流式场景下你是等全部收完再解析还是边收边解析等全部收完最简单但失去了流式的意义边收边解析体验好但需要处理不完整 JSON。4.2 增量解析的两种策略第一种策略是缓冲全量再解析。把所有 arguments 片段拼成一个完整字符串等流结束或者等收到 tool call 的结束标记后一次性json.loads。这种做法实现简单适合“结果最终要落库、中间过程不需要展示”的场景。缺点是用户在流式过程中看不到结构化字段的逐步填充。第二种策略是增量解析。维护一个缓冲区每收到一个片段就尝试解析解析失败就继续等下一个片段。这里的关键是“尝试解析”不能太频繁否则大 JSON 会反复解析浪费 CPU。我的做法是设置一个阈值缓冲区每增长 64 字节才尝试一次并且用一个简单的括号计数来判断 JSON 是否可能完整。import json class IncrementalJsonParser: def __init__(self): self.buffer self.last_attempt_len 0 self.threshold 64 def feed(self, chunk: str): self.buffer chunk if len(self.buffer) - self.last_attempt_len self.threshold: return None self.last_attempt_len len(self.buffer) if not self._maybe_complete(): return None try: return json.loads(self.buffer) except json.JSONDecodeError: return None def _maybe_complete(self): depth 0 in_string False escape False for ch in self.buffer: if escape: escape False continue if ch \\: escape True continue if ch : in_string not in_string continue if in_string: continue if ch in {[: depth 1 elif ch in }]: depth - 1 return depth 0这段代码的核心是_maybe_complete它通过括号配对和字符串状态跟踪来判断当前缓冲区是否可能是一个完整的 JSON。注意它只是“可能完整”因为括号配对正确不代表 JSON 合法所以后面还要 try 一次。这个判断能过滤掉绝大多数明显不完整的片段避免频繁抛异常。4.3 标签未闭合与截断的处理流式场景里最常见的问题就是“标签返回未完整”。比如模型在填一个长文本字段时流到一半网络断了或者达到 max_tokens 被截断你拿到的是一个没有闭合引号的半截 JSON。处理这种情况首先要区分是“暂时不完整”还是“永久不完整”。暂时不完整是流还在继续等下一个片段就好永久不完整是流已经结束但 JSON 仍然不合法。前者靠增量解析等待后者需要兜底。兜底策略我一般分三层。第一层是尝试修复用一些宽松的解析库或者自己写补全逻辑比如给未闭合的字符串补上引号给未闭合的对象补上右括号。第二层是降级如果修复不了就把已经解析出来的部分字段返回缺失的字段用默认值填充并在结果里标记partial: true。第三层是重试如果关键字段缺失触发一次非流式的重新调用用完整输出保证数据质量。提示max_tokens 一定要设得比预期输出大一些。结构化输出的 JSON 因为有字段名和格式符号实际 token 消耗比纯文本内容多不少。我一般按“内容预估 token 乘以 2.5”来设置上限。4.4 流式与落库的衔接时机流式输出和 MySQL 落库之间有个时机选择问题。是流结束就落库还是解析出完整对象就落库我的建议是解析出完整对象就落库不要等流结束。因为流结束可能因为各种原因延迟而完整对象一旦解析出来就说明数据已经齐了。落库之后如果流还在继续比如后面还有别的字段那是另一个字段的事可以走更新。但这里有个坑如果同一个请求可能产生多条记录比如一次抽取多个商品那流式过程中你会陆续解析出多个完整对象。这时候要按顺序落库并且用一个批次 ID 把它们关联起来方便后续查询和回滚。5. 从结构化对象到 MySQL 落地的完整链路5.1 表结构设计为结构化数据留足扩展位结构化输出的字段往往不是固定的今天抽商品名和价格明天可能要加库存和品牌。如果每次加字段都改表结构维护成本太高。我的做法是核心字段建独立列扩展字段用 JSON 列兜底。CREATE TABLE llm_extraction_result ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, batch_id VARCHAR(64) NOT NULL COMMENT 批次ID同一次请求的多条记录共享, trace_id VARCHAR(64) NOT NULL COMMENT 链路追踪ID, schema_name VARCHAR(64) NOT NULL COMMENT 使用的结构化schema名称, biz_key VARCHAR(128) DEFAULT NULL COMMENT 业务主键如订单号, core_field_1 VARCHAR(255) DEFAULT NULL COMMENT 核心字段示例名称, core_field_2 DECIMAL(12,2) DEFAULT NULL COMMENT 核心字段示例金额, extra_fields JSON DEFAULT NULL COMMENT 扩展字段存非核心的结构化数据, raw_output TEXT COMMENT 模型原始输出用于排查, is_partial TINYINT(1) DEFAULT 0 COMMENT 是否为部分解析结果, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_batch (batch_id), INDEX idx_trace (trace_id), INDEX idx_biz_key (biz_key), INDEX idx_created (created_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENTLLM结构化抽取结果表;几个设计要点。batch_id用来关联同一次请求产生的多条记录方便批量查询和回滚。trace_id关联日志系统出问题时能快速定位。raw_output存原始输出虽然占空间但排查问题时价值极高我建议至少保留最近一个月。is_partial标记部分解析结果业务层查询时可以过滤掉。extra_fields用 JSON 类型MySQL 5.7 以上都支持。它让你在不改表的情况下存新字段查询时用JSON_EXTRACT或者-操作符取值。但要注意JSON 列上的查询性能不如独立列所以只把不常查询的字段放进去。5.2 批量写入与事务控制结构化抽取往往是批量的一次处理几百上千条。逐条 insert 性能太差要用批量插入。INSERT INTO llm_extraction_result (batch_id, trace_id, schema_name, biz_key, core_field_1, core_field_2, extra_fields, raw_output, is_partial) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?, ?, ?, ?), ...批量插入的批次大小要控制。我实测下来每批 500 到 1000 条比较合适太小了网络往返开销大太大了单条 SQL 过长可能超过max_allowed_packet限制。如果你不确定先按 500 来观察写入耗时再调整。事务方面如果这批数据要么全成功要么全失败那就包在一个事务里。但如果数据量大长事务会占用大量 undo log 并且锁资源我建议分批提交每批一个事务。失败的那批单独重试不影响其他批次。注意批量插入时如果有一条数据违反约束比如字段超长整批都会失败。所以入库前要做一次字段长度校验把超长的截断或者标记出来别让一条脏数据拖垮整批。5.3 用 JDBC 流式读取大结果集落库之后下游可能要读取这些数据做分析。如果结果集很大一次性SELECT出来会撑爆内存。这时候要用 JDBC 的流式读取。以 MySQL 为例默认情况下 JDBC 会把整个结果集加载到内存。要启用流式读取需要设置fetchSize为Integer.MIN_VALUE并且结果集类型设为TYPE_FORWARD_ONLY。PreparedStatement ps conn.prepareStatement( SELECT id, core_field_1, extra_fields FROM llm_extraction_result WHERE batch_id ?, ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY ); ps.setFetchSize(Integer.MIN_VALUE); ps.setString(1, batchId); ResultSet rs ps.executeQuery(); while (rs.next()) { // 逐条处理内存占用恒定 }这里Integer.MIN_VALUE是 MySQL JDBC 驱动的一个特殊约定表示启用流式模式。设成其他值驱动会忽略。流式模式下 ResultSet 只能向前遍历不能回头处理逻辑要相应调整。流式读取期间连接是被占用的不能在这个连接上执行其他查询否则会报错。所以要么用独立连接要么处理完再复用。另外流式读取时如果处理太慢可能触发服务端的超时要留意net_write_timeout这个参数。5.4 幂等与去重LLM 调用可能因为重试产生重复数据落库时要考虑幂等。最简单的办法是用业务主键加唯一索引插入时用INSERT ... ON DUPLICATE KEY UPDATE。INSERT INTO llm_extraction_result (batch_id, trace_id, biz_key, core_field_1, core_field_2, extra_fields) VALUES (?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE core_field_1 VALUES(core_field_1), core_field_2 VALUES(core_field_2), extra_fields VALUES(extra_fields), updated_at CURRENT_TIMESTAMP;前提是biz_key上有唯一索引。如果业务上没有天然的唯一键可以用trace_id加记录序号拼一个。去重逻辑放在数据库层比放在应用层可靠因为并发场景下应用层的“先查后插”有竞态问题。6. 常见问题与排查技巧实录6.1 结构化输出失败的典型症状与定位我把实际遇到过的问题整理成一张速查表方便对照排查。症状可能原因排查方向解决手段模型返回纯文本不调工具提示词与 schema 冲突检查 system prompt 是否要求了别的输出格式精简提示词让 schema 主导字段名对但值为空字段描述不清看 description 是否说明了取值来源补充描述加示例值类型错误数字变字符串schema 类型定义不严检查是否用了宽松类型用严格类型加枚举约束流式解析频繁报错增量解析阈值太小看解析异常频率调大阈值加括号配对判断落库字段超长模型输出未限制长度看 raw_output 里字段实际长度schema 里加 maxLength入库前截断批量插入整批失败单条数据违反约束看错误信息定位到具体行入库前校验分批提交这张表里我特别想强调第一条。很多人写提示词时习惯加一句“请以友好的语气回复”结果模型真的用友好语气回复了完全无视了 tool 定义。结构化输出场景下system prompt 要尽量“冷”只描述任务和约束不要加任何关于语气、格式的额外要求让 schema 成为唯一的格式来源。6.2 模型“自作聪明”补全缺失字段这是很隐蔽的一个坑。你让它抽取订单信息用户没说收货地址模型觉得“订单应该有地址”就编了一个。这种错误在格式上完全合法字段名对、类型对但值是假的。解决办法有两个。一是在字段描述里明确写“如果输入中未提及请留空不要推测”。二是给字段加一个“来源”标记让模型同时返回每个字段是从原文哪句话抽出来的你可以在业务层校验来源是否存在。第二个办法成本高一些但对数据质量要求高的场景值得做。我一般对金额、日期、编号这类关键字段要求返回来源对描述性字段就不强求。来源信息可以存在extra_fields里不占独立列。6.3 流式输出中的乱序与重复流式传输偶尔会出现片段乱序或者重复虽然概率低但一旦发生你的增量解析就会拿到错误的 JSON。防御手段是在拼接时做校验。如果框架给每个片段带了序号就按序号拼接发现序号跳跃就丢弃当前缓冲重新开始。如果没有序号可以在拼接后校验 JSON 的合法性不合法就等下一个片段。重复片段会导致 JSON 里出现重复的键json.loads默认取最后一个这可能导致数据错误。可以在解析后检查键的数量是否与预期一致。提示生产环境里我建议对流式片段做一次哈希去重维护一个最近片段的哈希集合重复的直接丢弃。这个开销很小但能挡住大部分重复问题。6.4 MySQL 写入性能瓶颈的排查批量写入慢先看三个地方。第一是索引写入时索引也要更新索引越多越慢。如果这张表主要是写入、查询很少可以只保留必要索引。第二是innodb_flush_log_at_trx_commit默认是 1每次事务都刷盘改成 2 能大幅提升写入速度代价是极端情况下可能丢一秒数据。第三是批量大小前面说过 500 到 1000 比较合适但具体值要压测。我遇到过一次写入特别慢的情况排查半天发现是extra_fields这个 JSON 列上建了索引每次写入都要更新 JSON 索引开销很大。后来把 JSON 列上的索引去掉写入速度直接翻倍。JSON 列上的索引要慎用除非确实有高频的 JSON 字段查询需求。6.5 重试策略的设计结构化输出失败后的重试不能简单重发。第一次失败往往是因为模型对某个字段理解有偏差重发同样的请求大概率还是失败。有效的重试是把失败信息带回去。比如第一次返回的 JSON 缺少price字段重试时在提示词里加一句“上一次输出缺少 price 字段请确保包含”。或者把 schema 校验的错误信息作为反馈传给模型。这种“带反馈的重试”成功率比盲目重试高很多。重试次数我一般设两次第一次带反馈第二次如果还失败就降级处理把能解析的字段落库并标记is_partial同时告警。不要无限重试LLM 调用有成本而且有些输入模型就是处理不了重试一百次也没用。7. 一些实操中攒下来的经验schema 的字段数量控制在 15 个以内。超过这个数模型漏字段的概率明显上升。如果业务确实需要很多字段拆成多次抽取每次抽一组相关字段最后在代码里合并。这比一次性抽一大堆要可靠。流式场景下如果下游不需要实时看到结构化字段只是最终要落库那就别用增量解析直接缓冲全量再解析。增量解析的复杂度不低没有收益就别引入。MySQL 的 JSON 列虽然方便但别把它当万能垃圾桶。高频查询的字段一定要建独立列JSON 列只放低频查询的扩展数据。我见过把整个结构化对象塞进一个 JSON 列的后来要按某个字段查询只能全表扫描加应用层过滤性能惨不忍睹。落库前一定要做字段长度校验。模型输出的文本长度不可控VARCHAR(255)的列它可能给你返回 300 个字符。入库前统一截断或者拒绝别让数据库报错。截断要记录日志方便回溯。最后说一个关于withStructuredOutput这类封装的选型建议。如果你的框架已经提供了这个能力直接用别自己造轮子因为流式适配和重试兜底这些细节自己写很容易漏。但如果框架的封装不满足需求比如你需要自定义流式解析逻辑那就把封装层拆开只复用它的 schema 转换部分调用和解析自己控制。理解底层机制之后拆开用比整体替换要灵活得多。