LLM应用开发中的数据格式:从纯文本到流式输出的工程实践
1. 为什么数据格式是LLM应用中最容易被低估的环节做LLM应用开发的人十有八九都有过这样的经历模型跑通了Prompt调好了结果卡在数据格式上——JSON解析报错、多轮对话拼接错位、批量推理时输入格式对不上。这些问题不涉及模型本身的能力但排查起来极其耗时而且往往在项目集成阶段才暴露出来。我见过不少团队在Demo阶段用最随意的字符串拼接喂给模型效果看着还行一旦进入工程化阶段数据量上来、并发上来、多轮对话状态需要维护整个输入输出链路就崩了。根本原因在于LLM的输入输出格式不是随便定的它直接决定了你的系统能不能稳定解析、能不能扩展、能不能和上下游工具链对接。这篇文章围绕LLM开发中最常见的几类数据格式展开包括纯文本格式、JSON结构化格式、对话消息格式、以及流式输出的数据组织方式。我会从每种格式的设计动机讲起说清楚它解决什么问题、在什么场景下用、实际写代码时容易踩哪些坑。不管你是刚接触LLM API调用的新手还是已经在做多轮对话系统的开发者这些内容都能直接用到你的项目里。需要提前说明的是不同模型提供方的API细节会有差异但数据格式的底层逻辑是相通的。掌握了这些通用模式换任何一家模型服务都能快速上手。2. 纯文本格式最朴素也最容易出问题的起点2.1 纯文本输入的本质与适用边界纯文本格式就是直接把一段字符串作为模型的输入。这是最直观的方式也是很多人第一个Demo用的方式。比如你写一个情感分类的小工具直接把用户评论拼成一句“请判断以下评论的情感倾向xxx”然后发给模型。这种格式的优势在于简单、无需额外序列化、调试方便。但它的边界也很明显当你的输入包含多个逻辑上独立的部分时纯文本无法清晰区分它们的角色和边界。比如你要传一段系统指令、一段历史对话、一段用户当前问题如果全部拼成一个字符串模型可能会混淆哪些是指令、哪些是待处理内容。我在早期做文本摘要功能时就吃过这个亏。当时把原文和指令拼在一起结果模型有时候会把指令也当成待摘要的内容输出。后来改成用明确的分隔标记比如用三个换行或者特定的分隔符把指令区和内容区分开问题才解决。2.2 分隔符的选择与Prompt注入风险用纯文本格式时分隔符的选择比想象中重要。常见的做法是用###、---、或者XML风格的标签如content.../content来分隔不同区块。为什么推荐XML风格标签因为大量实验表明模型对尖括号标签的边界识别能力更强尤其是当内容本身包含换行和特殊符号时。比如下面这种结构instruction 请将以下文本翻译成英文。 /instruction content 今天天气很好适合出去散步。 /content这种写法比单纯用换行分隔要稳得多。但要注意如果用户输入的内容里本身就包含/content这样的字符串就可能造成边界混淆这就是所谓的Prompt注入风险。一个实用的防御手段是在拼接前对用户输入做转义处理把尖括号替换成全角字符或者HTML实体。注意纯文本格式下永远不要假设用户输入是“干净”的。任何直接拼接用户内容到Prompt里的操作都需要考虑注入和边界混淆的问题。2.3 纯文本输出的解析难题纯文本输入相对好处理纯文本输出的解析才是真正的麻烦。比如你让模型输出一个列表它可能用-开头也可能用1.开头还可能用•开头。你让模型输出JSON它可能在JSON前后加一句“好的以下是结果”导致json.loads直接报错。我处理这个问题的经验是在Prompt里明确约束输出格式同时在代码侧做容错解析。具体来说Prompt里要写清楚“只输出JSON不要包含任何其他文字”代码侧则用正则表达式先提取JSON片段再解析。比如import json import re def parse_llm_json(text): # 尝试直接解析 try: return json.loads(text) except json.JSONDecodeError: pass # 尝试提取代码块中的JSON match re.search(r(?:json)?\s*([\s\S]*?), text) if match: try: return json.loads(match.group(1)) except json.JSONDecodeError: pass # 尝试提取第一个花括号到最后一个花括号之间的内容 start text.find({) end text.rfind(}) if start ! -1 and end ! -1 and end start: try: return json.loads(text[start:end1]) except json.JSONDecodeError: pass raise ValueError(f无法从输出中解析JSON: {text[:200]})这个函数我用了很久能覆盖绝大多数模型输出JSON时的不规范情况。核心思路是层层降级先试最严格的解析不行再试提取代码块再不行试提取花括号范围。实测下来加了这层容错之后解析失败率从百分之几降到了万分之一以下。3. JSON结构化格式让模型输出可编程的数据3.1 为什么需要让LLM输出JSON纯文本输出适合给人看但如果你要把模型输出接入下游系统——比如存数据库、调另一个API、做条件分支——就需要结构化的数据。JSON是事实上的标准选择因为它通用、易解析、层次结构清晰。让模型输出JSON的典型场景包括信息抽取从文本中提取实体和关系、分类打标输出类别和置信度、函数调用参数生成输出工具调用所需的参数对象、多步骤任务规划输出步骤列表。这些场景的共同点是输出结果需要被程序进一步处理而不是直接展示给终端用户。3.2 在Prompt中约束JSON结构的几种写法让模型稳定输出JSONPrompt的设计很关键。我总结了几种有效的写法按可靠性从低到高排列第一种是自然语言描述。比如“请以JSON格式输出包含name和age两个字段”。这种方式最灵活但模型有时会自由发挥加一些你没要求的字段或者字段名拼写不一致。第二种是给出JSON Schema。把字段类型、是否必填、枚举值都写清楚。这种方式约束力更强但Prompt会变长而且模型仍然可能不严格遵守。第三种是给出一个具体的输出示例。这是我认为最有效的方式。模型看到示例后模仿的准确率明显高于纯描述。比如请从以下文本中提取人名和公司名以JSON格式输出。 示例输出 {person: 张三, company: 某某科技} 待处理文本 李四在某某网络公司担任工程师。实测下来给示例的方式在字段名一致性和格式稳定性上表现最好。如果配合低temperature参数比如0.1几乎可以做到百分之百的格式合规。3.3 JSON Schema校验与自动重试机制即使Prompt写得再好也不能保证模型每次都输出合法JSON。所以在工程上必须加一层Schema校验和自动重试。校验可以用Python的jsonschema库也可以用Pydantic做类型校验。我倾向于用Pydantic因为它同时提供了类型转换和校验和FastAPI等框架集成也方便。下面是一个典型的校验加重试逻辑from pydantic import BaseModel, ValidationError import json class PersonInfo(BaseModel): name: str age: int city: str 未知 def extract_with_retry(llm_client, prompt, max_retries3): for attempt in range(max_retries): raw llm_client.generate(prompt) try: data parse_llm_json(raw) return PersonInfo(**data) except (ValueError, ValidationError) as e: if attempt max_retries - 1: raise # 把错误信息反馈给模型让它修正 prompt f{prompt}\n\n上次输出有误{e}\n请重新输出正确的JSON。 return None这个模式的关键在于把校验失败的具体原因反馈给模型让它自我修正。比单纯重试同一个Prompt效果好得多。我在一个信息抽取项目里用这个方案首次成功率大概85%加上两轮重试后接近100%。3.4 嵌套JSON与数组输出的处理技巧当输出结构变复杂比如包含嵌套对象或数组时模型出错的概率会上升。常见的错误包括数组元素类型不一致、嵌套层级搞错、空值处理不当。处理嵌套结构时我的经验是尽量扁平化。如果业务允许把嵌套两层以上的结构拆成多个平级字段。比如不要输出{address: {city: 北京, district: 海淀}}而是输出{city: 北京, district: 海淀}。扁平结构对模型更友好解析也更简单。如果确实需要数组输出比如提取多个实体建议在Prompt里明确数组的最大长度并给出空数组的示例。这样模型知道没有结果时应该输出[]而不是null或省略字段。4. 对话消息格式多轮交互的基石4.1 role-content结构的设计逻辑现在主流LLM API的对话格式基本都采用role加content的结构。role标识消息的发出方常见取值有system、user、assistant有些API还有tool或function角色。content就是消息的具体内容。这种设计不是随便定的。它的核心目的是让模型能够区分不同来源的信息并据此调整生成行为。system消息通常承载全局指令和角色设定user消息是用户输入assistant消息是模型之前的回复。模型在生成时会根据消息序列的角色交替模式来理解对话的上下文结构。一个典型的多轮对话消息数组长这样[ {role: system, content: 你是一个专业的翻译助手。}, {role: user, content: 把你好翻译成英文}, {role: assistant, content: Hello}, {role: user, content: 那谢谢呢} ]模型看到这个序列就知道最后一条user消息是当前要处理的问题前面的assistant消息是历史回复system消息是全局设定。4.2 多轮对话中消息顺序与截断策略多轮对话有一个绕不开的问题上下文长度有限。当对话轮次多了消息数组会超出模型的上下文窗口。这时候就需要截断策略。最简单的截断是保留最近的N条消息。但这样会丢掉system指令导致模型行为漂移。所以通常的做法是始终保留system消息然后从最近的user消息往前保留直到接近长度上限。还有一种更精细的做法是保留首轮对话和最近几轮对话中间的部分做摘要压缩。这个方案实现复杂但在需要长期记忆的场景下效果更好。我在一个客服机器人项目里用过这种方案首轮用户描述的问题保留原文中间轮次用模型生成一段摘要最近三轮保留原文。这样既控制了长度又保留了关键信息。注意截断消息时一定要保证user和assistant消息成对出现。如果截断后出现连续的user消息或assistant消息模型可能会困惑。我遇到过截断后连续两条user消息导致模型把两条合并理解的情况。4.3 system消息的写法与常见误区system消息是控制模型行为最有力的工具但很多人没用好。常见的误区包括把system消息写得太长太杂、在system里放具体的数据、把本该放user的内容放到system里。我的经验是system消息只放三类内容——角色设定、行为约束、输出格式要求。具体的数据和当前任务描述应该放在user消息里。比如你是一个法律文档助手。你的回答需要满足以下要求 1. 只基于提供的文档内容回答不编造信息 2. 如果文档中没有相关信息明确说明文档中未提及 3. 回答使用简洁的书面语避免口语化表达这种system消息清晰、可复用换一个任务时只需要改user消息即可。如果把具体文档内容也塞进system那每次请求都要重新构造system消息失去了复用的意义。4.4 工具调用场景下的消息格式扩展当LLM需要调用外部工具时消息格式会进一步扩展。通常会增加tool_calls字段在assistant消息中和tool角色用于返回工具执行结果。这个流程的消息序列一般是user提问 → assistant输出tool_calls → tool角色返回执行结果 → assistant基于结果生成最终回复。理解这个序列对调试工具调用至关重要。我见过不少人在工具调用失败时不知道从哪里排查其实就是没搞清楚消息的流转顺序。一个实用的调试技巧是把完整的消息数组打印出来逐条检查role和content是否符合预期。大部分工具调用的问题都能通过这种方式定位。5. 流式输出格式实时体验背后的数据组织5.1 流式返回的数据单元与拼接逻辑流式输出让用户不用等模型生成完整回复就能看到内容逐步出现体验提升明显。但流式数据的格式和一次性返回不同需要特别处理。主流API的流式返回通常是Server-Sent Events格式每个数据块是一个JSON对象包含一个增量文本片段。比如data: {choices: [{delta: {content: 你}}]} data: {choices: [{delta: {content: 好}}]} data: {choices: [{delta: {content: }}]} data: [DONE]客户端需要把这些增量片段按顺序拼接成完整文本。拼接逻辑本身不复杂但有几个细节容易出错第一个块可能只包含role信息没有content、最后一个块可能是结束标记、不同块的字段结构可能略有差异。我在实现流式解析时通常会写一个状态机来跟踪当前是哪个choice、是否已经收到role、是否遇到结束标记。这样比简单的字符串拼接要稳健得多。5.2 流式场景下的JSON输出难题流式输出和JSON格式天然有冲突JSON必须是完整的才能解析但流式是逐步到达的。如果你让模型流式输出JSON在收到最后一个字符之前你拿到的都是不完整的JSON片段。解决这个问题的思路有两种。一种是等流式结束后再解析牺牲实时性换取解析可靠性。另一种是增量解析用一个流式JSON解析器逐步构建对象。Python里可以用ijson库做增量解析但实现复杂度较高。我的建议是如果下游系统需要结构化数据不要用流式输出JSON。可以让模型流式输出纯文本在客户端做展示同时另起一个非流式请求获取结构化结果。或者让模型输出NDJSON格式每行一个完整的JSON对象这样每收到一行就可以解析一行。5.3 流式输出的错误处理与中断恢复流式连接可能因为网络问题中断这时候已经接收到的内容怎么处理、要不要重试、重试后如何避免重复都是需要考虑的问题。一个实用的做法是记录已接收的文本长度重试时在Prompt中说明已生成的内容让模型从中断处继续。但这种方式并不总是可靠因为模型可能不记得自己之前生成了什么。更稳妥的方式是直接重新生成但在UI上保留已展示的内容等新内容到达后替换。另外要注意流式输出过程中如果发生错误有些API会在数据流中返回一个错误对象而不是直接断开连接。所以解析每个数据块时都要检查是否包含错误字段不能假设每个块都是正常的文本增量。6. 格式选型与工程化落地的经验总结6.1 不同场景下的格式选择对照把前面讲的几种格式放到一起对比选型时可以参考下表场景推荐格式理由简单分类、情感分析纯文本输入 纯文本输出结构简单无需额外解析信息抽取、实体识别纯文本输入 JSON输出输出需要程序处理JSON最通用多轮对话系统消息数组格式天然支持角色区分和历史管理工具调用消息数组 tool_calls扩展标准协议各框架兼容性好实时展示场景流式纯文本用户体验好解析简单批量数据处理纯文本或JSON输入 JSON输出便于批量化校验和重试这张表不是绝对的实际选型还要考虑模型提供方的支持情况。但核心原则不变输入格式要能清晰区分不同逻辑块输出格式要便于下游程序解析。6.2 格式校验的自动化测试方案数据格式的问题在开发阶段不容易暴露因为手动测试时输入输出都比较规范。一旦上线面对真实用户输入各种边界情况就出来了。所以建立格式校验的自动化测试非常必要。我的做法是维护一个测试用例集包含正常输入、边界输入、恶意输入三类。每次修改Prompt或解析逻辑后跑一遍测试集确保格式合规率没有下降。测试用例可以用JSON文件管理每条包含输入、期望的输出结构、以及校验规则。对于输出格式的测试不要只校验能否解析还要校验字段是否完整、类型是否正确、枚举值是否在允许范围内。这些校验用Pydantic模型定义一次测试和运行时都能复用。6.3 从Demo到生产格式层面的常见退化与修复很多项目在Demo阶段格式没问题上线后逐渐出现解析失败率上升。常见的退化原因包括用户输入分布变化导致模型输出格式漂移、Prompt在迭代中被改坏、模型版本升级导致行为变化。应对这些退化我的经验是建立格式合规率的监控指标。每次请求都记录解析是否成功按天统计合规率。一旦发现合规率下降超过阈值就触发告警。同时保留失败样本便于快速定位是输入问题还是Prompt问题。另外模型版本升级时一定要回归测试格式合规率。不同版本的模型对同一个Prompt的格式遵循程度可能有明显差异。我遇到过升级后JSON输出中多出注释的情况就是通过回归测试发现的。6.4 我踩过的三个格式相关的坑第一个坑是用字符串拼接构造消息数组。早期我图省事直接把历史对话拼成一个长字符串放在user消息里而不是用多条消息。结果模型经常把历史对话中的内容当成当前问题来回答。后来改成标准的多条消息格式问题消失。第二个坑是忽略JSON中的Unicode转义。模型输出的JSON里有时会把中文转义成\uXXXX形式直接解析后得到的是转义后的字符串。需要在解析后做一次解码或者用支持自动解码的解析器。第三个坑是流式输出时按行分割但没处理跨块JSON。有些API的流式返回中一个JSON对象可能跨多个数据块简单按行分割会得到不完整的JSON。正确的做法是按SSE协议解析以空行作为消息分隔符而不是按换行符。这三个坑都不涉及高深技术但每一个都花了我不少时间排查。写出来是希望后来者能直接绕过。6.5 格式设计的可扩展性考量最后说一点关于格式设计的前瞻性。很多人在定义输出格式时只考虑当前需求结果业务一变就要改Prompt、改解析代码、改测试用例牵一发动全身。我的建议是在输出JSON中预留一个version字段和一个metadata字段。version用于标识当前输出格式的版本方便后续做兼容处理。metadata用于存放一些非核心的附加信息比如模型名称、生成时间、置信度等。这样当业务需要增加字段时可以往metadata里加不影响核心结构的稳定性。这个做法看起来多此一举但在项目迭代半年以上后你会感谢当初留了这两个字段。我负责过的一个项目就是因为早期没留扩展位后来每次加字段都要全量回归测试成本很高。格式这件事说到底是LLM应用和传统软件之间的桥梁。桥修得稳不稳直接决定了整个系统能不能跑得远。希望这些经验对你有用。