☰
LLM应用开发中数据格式设计与结构化输出实战指南
2026/10/14 5:45:07 网站建设 项目流程

1. 为什么“数据格式”是LLM应用里最容易被低估的环节

很多人第一次接触大语言模型应用开发,注意力几乎全放在模型选型、提示词调优、参数调参上,觉得只要模型够强、提示词写得够好,效果自然就上去了。但真正做过几个落地项目之后你会发现,大量效果问题、稳定性问题、成本问题,根子其实出在数据格式上。模型再强,喂进去的数据结构一团糟,输出照样没法用;反过来,格式设计得合理,哪怕用的是中等规模的模型,整条链路的可靠性也能提升一大截。

我先把结论摆在前面:LLM 场景下的“数据格式”不是单纯指 JSON 还是 YAML 这种文件后缀的区别,它至少包含三个层次——输入给模型的上下文格式、模型要求的结构化输出格式、以及系统之间流转的数据交换格式。这三层经常被混为一谈,导致很多人在调试时抓不到重点。比如有人抱怨“模型不听话,让它输出 JSON 它偏要加一段解释”,这其实是输出格式约束的问题;有人抱怨“检索增强效果差”,这往往是上下文拼接格式的问题;还有人抱怨“多轮对话越聊越乱”,这多半是消息数组格式没设计好。

这篇文章面向的是已经上手过 LLM 接口调用、准备把 demo 做成可用系统的开发者,也适合刚接触这块、想少走弯路的朋友。我会把常见的几类数据格式拆开讲清楚:它们各自解决什么问题、长什么样、什么场景该用哪个、实际写代码时有哪些坑。全程用从业者的视角聊,不堆术语,能上代码的地方就上代码,能对比的地方就上表格。

先说一个我踩过的真实教训。早期做一个信息抽取的小工具,我图省事,让模型直接输出一段自然语言,然后用正则去抠字段。结果模型偶尔换个说法、加个标点,正则就崩了,维护成本高得离谱。后来改成让模型输出严格 JSON,配合校验和重试,整条链路立刻稳了。这个转变让我意识到,在 LLM 应用里,格式设计本身就是一种工程能力,它决定了你的系统是脆弱的还是健壮的。

2. 喂给模型的上下文:几种主流组织方式与取舍

2.1 纯文本拼接:最简单也最容易失控

最原始的方式就是把所有信息拼成一个大字符串丢给模型。系统提示、用户问题、参考资料、历史对话,全部用换行或者分隔符连起来。这种方式上手最快,几乎不需要任何结构设计,适合一次性、临时性的调用。

但它的问题也很明显。第一,边界模糊。模型分不清哪段是系统指令、哪段是用户输入、哪段是外部资料,一旦用户输入里混入了类似指令的内容,就容易出现指令被覆盖的情况。第二,难以程序化处理。你想在代码里单独替换某一段、统计某一段的长度,都得靠字符串查找,非常别扭。第三,不利于缓存和复用。很多平台的上下文缓存是按前缀匹配的,纯文本拼接如果顺序不稳定,缓存命中率会很低,成本直接上去。

我一般只在两种情况下用纯文本拼接:一是做快速验证,二是上下文非常短、结构非常固定的场景。只要项目稍微正式一点,我都会换成结构化程度更高的方式。

2.2 角色消息数组:对话类应用的事实标准

现在主流接口基本都支持一种“消息数组”的格式,每条消息带一个角色字段,常见角色有系统、用户、助手三种。它长这样:

[ {"role": "system", "content": "你是一个严谨的技术助手,只基于给定资料回答。"}, {"role": "user", "content": "帮我总结下面这段内容:..."}, {"role": "assistant", "content": "好的,这段内容主要讲了三点..."}, {"role": "user", "content": "第二点能展开说说吗?"} ]

这种格式最大的好处是职责清晰。系统消息放全局约束,用户消息放当前输入,助手消息放历史回复,模型对这三类的处理优先级是不一样的。多轮对话场景下,这种结构几乎是必选项,因为它天然表达了“谁在什么时候说了什么”。

实际用的时候有几个细节要注意。系统消息尽量放在最前面,而且内容要稳定,不要每轮都变,否则会破坏缓存。历史消息不能无限堆,超出上下文窗口就得做截断或者摘要,截断策略我后面会单独讲。还有一个容易被忽略的点:助手消息里的内容如果是模型之前生成的,最好原样保留,不要做二次加工,否则模型会觉得自己“说过的话”被篡改了,容易产生混乱。

2.3 带工具调用的消息格式:多了一层结构约束

当模型需要调用外部函数时,消息数组会多出工具相关的角色和字段。典型结构里会有工具定义、工具调用请求、工具执行结果这几部分。工具定义通常是一个独立的数组,描述每个工具的名字、用途和参数结构;模型决定调用时,会返回一个带工具名和参数的调用请求;你的代码执行完再把结果作为一条特殊角色的消息塞回去。

{ "tools": [ { "name": "get_weather", "description": "查询指定城市的天气", "parameters": { "type": "object", "properties": { "city": {"type": "string", "description": "城市名称"} }, "required": ["city"] } } ] }

这套格式的价值在于把自然语言决策和结构化执行打通了。模型负责“决定调什么”,你的代码负责“真正去调”,两边通过结构化数据交接,避免了让模型直接生成可执行代码带来的风险。参数结构定义得越清楚,模型调用越准。我见过不少项目工具调用老出错,最后发现是参数描述写得太含糊,模型只能靠猜。

2.4 上下文拼接的顺序与分隔策略

不管用哪种格式,只要涉及把多段资料塞进上下文,顺序和分隔就是绕不开的问题。常见的做法是把最相关的资料放最前面或最后面,因为模型对首尾位置的注意力通常更强。分隔上,纯文本场景我会用明确的分隔标记,比如三个等号或者特定标签,让模型能识别边界。

=== 资料 1 开始 === ... === 资料 1 结束 === === 资料 2 开始 === ... === 资料 2 结束 ===

结构化场景下,更好的做法是给每段资料带上元信息,比如来源、时间、相关度分数,让模型在引用时能带上出处。这在做检索增强时特别有用,用户问“这个结论哪来的”,模型能直接说出资料来源,可信度立刻不一样。

提示:上下文里塞的资料不是越多越好。实测下来,超过一定数量后,模型对中间部分的资料利用率会明显下降,这就是常说的“中间遗忘”。与其堆十段低相关资料,不如精选三段高相关的。

3. 让模型吐出结构化结果:输出格式的约束手段

3.1 为什么自然语言输出在工程上不可靠

让模型用自然语言回答,人看着舒服,但程序处理起来很痛苦。同一个意思,模型这次说“价格是 99 元”,下次说“售价为 99 元”,再下次说“定价 99 元”。你写正则要覆盖多少种情况?更别说字段缺失、顺序变化、多值合并这些问题。

工程系统需要的是可预测、可校验、可解析的输出。这就要求我们把模型的输出约束成结构化格式,最常见的就是 JSON。一旦输出是合法 JSON,你的代码就能直接反序列化成对象,字段缺失能检测,类型不对能报错,整条链路从“靠运气”变成“靠规则”。

3.2 JSON 模式与结构化输出:从“请求”到“强制”

早期让模型输出 JSON,全靠提示词里写“请只输出 JSON,不要加任何解释”。这招时灵时不灵,模型心情好就听话,心情不好就给你加一段“好的,以下是结果:”。后来平台陆续推出了更强的约束机制,大致分两档。

一档是JSON 模式,它保证输出是合法 JSON,但不保证字段符合你的预期。也就是说,它可能给你一个{"result": "..."},字段名对不对、类型对不对,它不管。另一档是结构化输出,你提供一个 schema,模型必须按这个 schema 来,字段名、类型、必填项都被约束住。这一档的可靠性高得多,适合生产环境。

{ "type": "object", "properties": { "name": {"type": "string"}, "price": {"type": "number"}, "tags": {"type": "array", "items": {"type": "string"}} }, "required": ["name", "price"], "additionalProperties": false }

注意最后那个additionalProperties: false,它能防止模型自作主张加字段。我强烈建议在生产 schema 里都加上这一条,否则模型偶尔会塞一些你没定义的字段进来,下游解析时容易出意外。

3.3 Schema 设计的几个实战原则

Schema 不是越复杂越好。我总结了几条经验。第一,字段尽量扁平,嵌套层级别超过三层,太深了模型容易搞错层级关系。第二,枚举值要写全,如果某个字段只能是几个固定值之一,就在 schema 里用枚举列出来,别让模型自由发挥。第三,必填项要克制,只把真正必需的字段设为必填,其他设为可选,否则模型为了凑必填项容易编造内容。第四,字段名用英文、语义清晰,别用缩写,模型对清晰命名的理解更准。

还有一个细节:如果某个字段是数组,最好在描述里说明数组元素的含义和大致数量范围。比如“tags 是 1 到 5 个关键词”,模型生成时就有个边界感,不会一口气给你二十个。

3.4 校验失败后的重试与降级策略

再强的约束也不能保证百分之百成功。网络抖动、模型偶发异常、输入过于刁钻,都可能导致输出不符合 schema。所以校验和重试是必须的。我的做法是:拿到输出先做 schema 校验,失败就把错误信息连同原始输出一起塞回给模型,让它修正。通常一次重试就能解决大部分问题。

def parse_with_retry(raw, schema, max_retry=2): for i in range(max_retry + 1): try: data = json.loads(raw) validate(data, schema) return data except Exception as e: if i == max_retry: raise raw = call_model(f"上次输出有误:{e},请修正后重新输出")

如果重试几次还是不行,就要有降级方案。降级可以是返回一个默认结构、标记为待人工处理、或者切换到更宽松的解析逻辑。关键是不能让整条链路卡死在一个格式错误上。

注意:重试时不要把原始输出截断,完整地给模型看,它才能准确定位问题。截断了反而让它猜,越改越乱。

4. 系统间流转:LLM 数据格式与外部世界的对接

4.1 从模型输出到业务对象的映射

模型输出的 JSON 只是中间态,最终要变成业务系统能用的对象。这一步的映射看似简单,实则有不少讲究。字段名可能对不上,模型输出user_name,业务对象要userName;类型可能对不上,模型输出字符串"99",业务要数字99;结构可能对不上,模型输出扁平结构,业务要嵌套结构。

我的建议是加一层显式的映射层,不要图省事直接把模型输出当业务对象用。映射层里做字段重命名、类型转换、默认值填充、结构重组。这层代码虽然啰嗦,但它是隔离模型波动和业务稳定的缓冲带。模型那边格式变了,只改映射层;业务那边字段变了,也只改映射层。

4.2 流式输出的数据格式:逐块拼接的坑

对话类应用为了体验,基本都用流式输出,一个字一个字往外蹦。流式返回的数据格式和一次性返回不一样,它是一串数据块,每个块里带一小段内容。拼接这些块的时候有几个坑。

第一,块边界可能切断多字节字符。中文一个字占多个字节,如果按字节切,可能把一个字切成两半,直接拼接会出乱码。好在主流接口一般按字符或 token 切,这个问题不常见,但自己实现底层协议时要留意。第二,结构化输出和流式输出经常冲突。流式过程中你拿到的是半截 JSON,没法直接解析,得等全部结束再解析。所以如果既要流式体验又要结构化结果,通常的做法是流式展示给用户看,同时后台累积完整内容,结束后再解析。第三,结束标志要处理好,别把结束信号也当成内容拼进去。

4.3 多模态数据的格式组织

现在很多场景不止有文本,还有图片、音频。多模态数据的格式组织核心是把不同模态的内容放进同一个消息结构里。常见做法是消息的 content 字段不再是一个字符串,而是一个数组,数组里每个元素标明类型和内容。

{ "role": "user", "content": [ {"type": "text", "text": "这张图里有什么?"}, {"type": "image_url", "image_url": {"url": "..."}} ] }

这种格式的好处是文本和图片的相对位置可以表达,模型能理解“先看这段文字再看这张图”还是反过来。实际用的时候要注意图片的尺寸和格式限制,太大或格式不支持会直接报错。音频类似,通常要转成特定编码再传。

4.4 长上下文与分块数据的格式设计

处理长文档时,不可能一次性全塞进去,得切块。切块本身是格式问题:按固定长度切、按段落切、按语义切,效果差别很大。切完之后每块最好带上位置信息,比如第几段、属于哪个章节,这样模型引用时能说清楚出处。

块与块之间还要考虑重叠。完全不重叠可能把一句话切断,导致语义不完整;重叠太多又浪费上下文。我一般设 10% 到 20% 的重叠,实测下来比较平衡。检索时召回多个块,再按相关度排序拼接,这个拼接格式前面讲过,带上来源和分数,模型用起来更准。

5. 格式选型的决策逻辑与常见误区

5.1 一张表看清各格式的适用场景

格式类型典型场景优点主要风险
纯文本拼接快速验证、短上下文上手快、无结构负担边界模糊、难维护
角色消息数组多轮对话、通用调用职责清晰、平台通用历史膨胀、缓存敏感
工具调用格式需要外部能力决策执行分离、安全参数描述要求高
JSON 模式需要结构化但字段灵活保证合法 JSON字段不受约束
结构化输出生产级信息抽取字段类型强约束schema 设计有门槛
多模态数组图文音混合输入模态位置可表达尺寸格式限制多

这张表不是让你死记,而是帮你建立判断框架。拿到一个需求,先问自己:输出要不要程序处理?要,就上结构化;上下文长不长?长,就考虑分块和缓存;要不要调外部能力?要,就设计工具格式。顺着这几个问题走,选型基本不会跑偏。

5.2 误区一:格式越复杂越专业

我见过一些项目,schema 设计得极其复杂,嵌套五六层,字段几十个,结果模型根本填不对,重试率居高不下。格式的目的是降低不确定性,不是炫技。能用扁平结构解决的,别嵌套;能用三个字段表达的,别搞十个。复杂度应该花在真正需要约束的地方,其他地方越简单越好。

5.3 误区二:忽略 token 成本与格式的关系

格式直接影响 token 消耗。JSON 里的花括号、引号、字段名,都是要计费的。一个字段名userName比n多好几个 token,字段一多,累积起来很可观。如果调用量巨大,字段命名和结构设计就得考虑成本。当然,可读性和可维护性优先,不能为了省 token 把字段名缩成天书,那样维护成本更高。平衡点在于:去掉冗余,保留清晰。

5.4 误区三:把格式约束完全交给提示词

提示词能引导格式,但不能保证格式。真正可靠的格式约束要靠 schema、校验、重试这套工程手段。提示词里写“请输出 JSON”是必要的,但只靠它是不够的。我一般把提示词里的格式说明当作“第一道引导”,把 schema 校验当作“最后一道防线”,两道配合,成功率才高。

6. 我在实际项目里踩过的格式坑与应对

6.1 中文标点导致的 JSON 解析失败

有一次做信息抽取,模型输出的 JSON 里,字符串值用了中文引号,导致json.loads直接报错。排查了半天才发现是模型在生成中文内容时,顺手把引号也“中文化”了。解决办法有两个:一是在提示词里明确要求使用英文标点;二是在解析前做一次标点归一化,把中文引号替换成英文引号。我两个都做了,之后基本没再出过这个问题。

def normalize_punctuation(s): return s.replace("“", "\"").replace("”", "\"").replace("‘", "'").replace("’", "'")

6.2 模型在 JSON 外面套了一层解释

即使提示词写了“只输出 JSON”,模型偶尔还是会加一句“好的,以下是结果:”。这在结构化输出模式下基本不会出现,但在 JSON 模式下时有发生。我的处理是提取第一个左花括号到最后一个右花括号之间的内容,再解析。这个简单的截取能救回大部分被“套壳”的输出。

def extract_json(s): start = s.find("{") end = s.rfind("}") if start == -1 or end == -1: raise ValueError("未找到 JSON 内容") return s[start:end + 1]

6.3 数组字段时有时无的处理

schema 里定义了数组字段,模型有时候给空数组,有时候干脆不给这个字段。如果代码里直接遍历,遇到字段缺失就崩了。我的习惯是在映射层统一补默认值,数组字段默认空数组,对象字段默认空对象,字符串默认空串。这样下游代码不用到处判空,逻辑干净很多。

6.4 长文本截断引发的格式残缺

上下文超长时,如果粗暴截断,可能把一段 JSON 从中间切断,导致解析失败。我的做法是在拼接上下文时就预留输出空间,确保输入不会挤占输出。具体来说,先算好模型的最大上下文窗口,减去预期输出长度,再决定输入能放多少。宁可少放点资料,也别让输出被挤没了。

提示:不同模型的上下文窗口和输出上限不一样,写代码时最好把这些参数做成配置项,换模型时改配置就行,别硬编码在逻辑里。

7. 一套可复用的格式处理流程

把前面这些串起来,我通常会在项目里搭一套固定的格式处理流程,大致分五步。第一步,定义 schema,明确输出结构,字段尽量扁平、必填项克制。第二步,构造输入,按角色消息数组组织,系统消息稳定,历史消息做截断或摘要。第三步,调用模型,优先用结构化输出模式,退而求其次用 JSON 模式。第四步,校验与重试,解析失败就带着错误信息重试,重试上限设两到三次。第五步,映射到业务对象,做字段重命名、类型转换、默认值填充。

这套流程看起来步骤多,但每一步都很薄,代码量不大,换来的是整条链路的稳定性。我做过对比,加了这套流程之后,格式相关的线上问题下降了八成以上。前期多花一两天搭好,后面省下的排查时间远超投入。

def llm_pipeline(user_input, schema, history=None): messages = build_messages(user_input, history) raw = call_model(messages, schema=schema) data = parse_with_retry(raw, schema) return map_to_business(data)

最后分享一个小心得:把 schema 当成接口文档来维护。每次改 schema,都当成一次接口变更,记录改了什么、为什么改、影响哪些下游。这样团队协作时不会乱,新人接手也能快速看懂。格式这东西,设计时多花点心思,维护时就能少掉很多头发。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询