深夜盯着终端里那坨缩进错乱的 JSONchoices[0].message.content返回的是个空字符串finish_reason写着length而你的代码在下一行直接json.loads()崩了——这个场景凡是用过大模型接口做事的人都不陌生。第一次调通接口只花了五分钟把content打印出来看到一段通顺的话感觉这事已经成了真正上线跑起来之后才发现返回结果这件事根本没有拿到了这么简单。它可能是空的、被截断的、包在 Markdown 代码块里的、混着解释性废话的、被限流打回来的甚至同一条 prompt 今天和明天给出两种结构。这篇东西讲的就是调用大模型 API 之后、返回结果落到你程序里这一段路的全部细节请求体里哪个字段真正决定了输出长什么样返回体里除了content还有哪些必须读的字段流式输出为什么总在奇怪的地方少一个字参数怎么调才不让结果每次都不一样以及出错的时候按什么顺序排查。适合已经调通过一次接口、准备把它塞进真实业务流程里的开发者也适合正在评估要不要自己接一层封装的团队。我没有兴趣复述官方文档里能查到的字段说明重点放在文档不会写、只有踩过才知道的那部分。1. 从请求体到返回体一次调用链路里真正决定输出的三个开关理解这一次往返的本质比记住任何 SDK 方法名都重要。整条链路其实是纯函数式的你把一个 JSON 发过去服务端跑一遍推理回一个 JSON。没有会话、没有记忆、没有上下文延续服务端不记得你上一秒问过什么所有的对话感都是你在客户端把历史消息一遍遍重新塞进去伪造出来的。想明白这一点后面 90% 的坑都能自己推出来。1.1 请求体的四块拼图模型标识、消息数组、采样参数、输出约束一次请求发出去的字段看上去有十几个但真正影响返回结果形态的只有四块。第一块是模型标识它决定了服务端加载哪套权重同一个 prompt 换模型标识输出长度、语气、格式遵循度都会变这不是玄学是不同模型在指令跟随上的训练差异。第二块是消息数组也就是常说的多轮结构每条消息带角色标签系统角色负责定规则用户角色负责提要求助手角色负责塞历史回答。第三块是采样参数管的是模型在候选词里怎么挑直接决定输出的确定性程度。第四块是输出约束包括最大输出长度、是否要求结构化返回、是否声明工具调用等。消息数组里最容易出问题的是系统角色那一条。很多人把格式要求写在用户消息末尾比如请以 JSON 输出结果跑到第三百次调用时模型突然开始返回解释性文字。原因不复杂用户消息在长上下文里权重会被稀释而系统消息位置靠前、作用贯穿全局。我自己的习惯是把所有硬约束——输出格式、字段名清单、禁止事项、字数上限——全部钉在系统消息里用户消息只放本次任务的具体数据两边职责分清楚之后格式崩坏的概率肉眼可见地往下掉。请求字段实际作用常见误用模型标识选择权重决定整体能力与风格用同一个标识跑所有任务长文任务和分类任务不分消息数组承载任务描述与上下文只发一条用户消息把规则也塞进去采样温度控制候选词分布的陡峭程度抽取类任务也开高温结果每次不一样最大输出长度硬性截断阈值设得过小返回半句话还以为模型能力不行结构化约束强制输出符合给定结构只靠自然语言提示不做客户端校验这里还有一个成本层面的隐性事实消息数组越长输入侧的计费越高而且延迟几乎线性增长。多轮对话如果无脑把全部历史带上去跑到第十轮的时候输入 token 可能是第一轮的五倍。真正做过线上服务的人一般会加一层历史压缩——保留最近几轮原文更早的对话压成一段摘要再塞进去。这个动作看着朴素但对单次调用成本和首字延迟的改善非常直接。1.2 返回体解剖content只是冰山露在水面上的那一角新手拿到返回体眼睛只盯着content。但一个完整的返回结构里至少还有四个字段需要读。结束原因告诉你这次输出是怎么停下的是正常停、被长度截断、被内容策略拦下还是模型决定去调工具了。用量统计给出输入和输出各消耗了多少 token这是你做预算和成本核算的唯一依据。响应标识用于和服务端对账出问题时拿它去查日志比描述现象有效得多。服务端版本标记在部分服务商那里会返回同一模型名底下权重可能悄悄更新如果你的业务对输出格式极度敏感这个字段值得记进日志。我在一个文本抽取的小项目里吃过亏脚本跑得很顺每天定时抽取几百条记录某天开始成功率掉到七成。日志里只打了content什么都看不出来。后来把结束原因和用量统计都补进日志才发现在失败的那批请求里结束原因全是长度截断——上游数据那几天字段变长了输出被硬性截在半截解析自然失败。问题不在模型在于我把最大输出长度设成了刚够用的值没有任何余量。import requests resp requests.post( https://api.example-llm.com/v1/chat/completions, headers{ Content-Type: application/json, Authorization: fBearer {API_KEY}, }, json{ model: general-chat-1, messages: [ {role: system, content: 你只输出 JSON字段为 title、tags、summary。}, {role: user, content: raw_text}, ], temperature: 0.2, max_tokens: 1024, }, timeout(10, 90), ) body resp.json() choice body[choices][0] content choice[message][content] reason choice.get(finish_reason) usage body.get(usage, {}) if reason ! stop: # 不要在这里直接抛异常先记下来走降级分支 log.warning(abnormal finish: %s, usage%s, reason, usage)上面那段代码里最值得注意的不是请求部分而是finish_reason ! stop这个判断。它应该是你所有调用代码里的标配而不是可选项。很多人写调用逻辑时默认每次都能拿到完整回复只处理网络异常结果遇到截断就整条流程静默出错。1.3 为什么五行代码跑通的 demo接进业务后问题成堆因为 demo 和业务对正确的定义完全不同。demo 只要输出一段看着像样的话就算成功业务要求每次输出都能被下游程序消费字段齐全、类型正确、长度可控、失败可恢复。这两者之间的差距全在返回结果的处理上。业务场景里一次调用失败不是重跑一下而是数据缺一条、用户看到一次报错、批处理任务卡在半路。还有一个容易被低估的因素是高并发下的长尾延迟。单次调用平均两秒看着很美但分布的长尾可能拖到三十秒。如果你的调用代码没有单独设置读超时一个慢请求就能把工作线程占住几十个这样的请求堆起来整个服务就像卡死了一样。我的做法是把连接超时和读超时分开设置连接超时给五到十秒读超时按任务类型给三十到九十秒并且给整批任务加一个总体的时间预算超预算的任务直接进重试队列而不是硬等。2. 返回的不是字符串把content从一段话变成一个能用的数据结构content的类型是字符串这个类型定义害了不少人。它看起来是结构化的 JSON本质上是模型生成出来的文本——模型没有任何机制保证这段文本一定合法、一定完整、一定符合你要求的字段。把它当字符串处理再用解析器去赌它合法等于把程序的正确性建立在概率上。正确姿势是把它的每一处不可靠都当成必然会发生来处理。2.1 结束原因的取值对照与各自的补救动作结束原因是返回结果里信息密度最高的字段但它的取值列表很多人从没完整看过。不同服务商的命名略有差异逻辑上基本可以归成五类。正常结束意味着模型自己认为说完了一般可以直接用。长度截断意味着撞到了你设的最大输出上限输出是半截的。内容拦截意味着输出被内容策略打断可能前半段正常后半段没了。工具调用意味着模型没有输出文本而是给出了一个结构化调用请求这时候content往往是空的。空值在某些异常路径下出现说明服务端没能给出明确结论。结束原因现象补救动作正常结束输出完整直接消费但仍需做结构校验长度截断文本戛然而止JSON 缺右括号调大输出上限或把任务拆成多段分别抽取内容拦截输出突然中断记录原文与响应标识走人工复核不要自动重试同一 prompt工具调用content为空另有调用结构不要读content走工具分支空值无明确结论视为失败按可重试错误处理这张表里最值得说开的是长度截断。很多人的第一反应是把上限直接调到模型允许的最大值这招有效但很贵——输出侧计费按实际生成量算但你设的上限会影响服务端调度而且长输出意味着更长的等待。更聪明的做法是按任务类型分别设上限分类和抽取类任务给三五百就够摘要类给八百到一千二长文生成才需要放开。另外在系统消息里明确写最多输出 N 字双管齐下实际输出很少会顶到硬上限。2.2 三种最常见的脏数据空回复、半截 JSON、原地复读空回复的成因有好几种得分开看。第一种是模型把全部输出预算花在了看不见的地方比如在内部推理上消耗完 token最后content是空的这类情况调大上限就能解决。第二种是内容策略在极早期就介入输出直接被掐断。第三种是请求本身有问题比如消息数组里出现了空字符串角色或者系统消息过长挤占了输出空间。区分方法很简单看用量统计里的输出 token 数如果输出 token 明显大于零但content为空基本属于第一或第二种如果输出 token 是零问题在请求侧。半截 JSON最容易发生在字段值里包含长文本的时候。模型的生成是逐字的它没办法在开始输出前先算好整段能否在上限内写完所以一旦撞线右括号就没了。应对方式有两层第一层是让输出结构先关键后冗长把必须存在的短字段放在前面长文本摘要放最后这样即使截断前面的字段仍然可解析第二层是客户端做括号补全和截断修复能救多少救多少。原地复读是另一种让人头疼的现象模型在某句话上反复打转输出几百个相同片段。这通常和采样参数、上下文里的重复片段有关。温度过低配合某些模型确实更容易出现僵持适当调高温度、在系统消息里明确不要重复、不要复述输入或者在检测到连续重复片段时直接中断并重试都是可行的处理。2.3 让 JSON 真正可用的三层加固约束、容错、校验重试只靠一句请输出 JSON成功率大概在八成到九成之间波动任务越复杂掉得越多。要把它做到接近百分之百可用需要三层叠加。第一层是提示词约束。不要只说输出 JSON要给出一段完整的结构示例字段名、类型、允许的枚举值全部写死并明确不要输出任何解释性文字、不要使用代码块包裹。有些服务商提供强制的结构化输出模式开启之后返回的一定是合法 JSON能用就用这是最省事的一层。第二层是解析容错。模型经常会不听话地加上 Markdown 代码块标记或者前置一句好的结果如下。解析前先剥掉这些包装再定位第一个左括号和最后一个右括号之间的内容。import json, re def parse_json_loose(text: str) - dict: if not text: raise ValueError(empty content) cleaned text.strip() # 剥掉可能存在的代码块围栏 cleaned re.sub(r^(?:json)?\s*|\s*$, , cleaned, flagsre.MULTILINE).strip() # 截取最外层的括号区间 starts [i for i in (cleaned.find({), cleaned.find([)) if i ! -1] if not starts: raise ValueError(no json boundary found) begin min(starts) end max(cleaned.rfind(}), cleaned.rfind(])) if end begin: raise ValueError(unclosed json) candidate cleaned[begin:end 1] try: return json.loads(candidate) except json.JSONDecodeError: # 常见修复去掉尾随逗号 fixed re.sub(r,\s*([}\]]), r\1, candidate) return json.loads(fixed)第三层是结构校验加重试。解析成功不等于内容可用字段可能缺失、类型可能不对、枚举值可能越界。用一份手写的校验函数过一遍不通过就把错误信息喂回模型让它修正最多重试两轮。这个带着错误反馈重试的技巧比盲目重跑有效得多因为模型能看到自己错在哪。重试时记得把温度调低甚至调到零减少二次跑偏的可能。提示重试一定设上限。我见过最贵的一次事故是某段代码在字段校验失败后无条件递归重试上游数据里恰好有一批永远无法满足校验的记录一晚上烧掉了当月大半预算。3. 流式返回分块解析里那些少一个字和多一段的细节非流式调用是把整个回答算完一次性返回流式则是一边生成一边往回吐。这两者的差别不只是看起来更快它改变了整个错误处理模型非流式是一次性事务成功或失败流式是一条持续的数据流中途可能断、可能乱序、可能缺尾。3.1 什么场景必须流式什么场景用流式纯属自找麻烦判断标准其实只有一条用户是否在盯着屏幕等待第一段文字出现。聊天式界面、写作辅助、代码补全这类人机实时交互场景首字延迟直接决定体验流式是刚需——用户看到文字在动主观等待感会大幅下降。反过来后台批处理、定时抽取、离线生成报表这些没有人在等的场景用流式毫无收益反而增加了拼装逻辑和出错面。我见过有团队在离线任务里也上了流式理由是延迟低结果为了处理断流写了三百行状态机而任务总耗时几乎没变。还有一个隐藏的取舍点流式下你拿不到完整的用量统计。多数服务商会在流的最后一块或者某个附加字段里返回汇总用量但如果流在中途断了这一次调用的消耗你就无从得知。对成本核算敏感的业务可以在流结束后单独做一次估算或者干脆对关键路径用非流式、对交互路径用流式。3.2 半包、粘包与结束标记缓冲区的正确写法流式返回通常是逐行传输的文本协议每行以固定前缀开头最后以一个特殊的结束标记收尾。这里有两个经典陷阱。半包是指一次网络读取只拿到了半行数据如果你直接按行切分解析这半行就会失败。粘包是指一次读取拿到了两行甚至多行如果你按一次读取一个事件处理就会漏掉后面的内容。正确做法是用一个缓冲区累积字节每次只处理能构成完整行的部分。def stream_chat(messages, modelgeneral-chat-1): payload {model: model, messages: messages, temperature: 0.3, max_tokens: 2048, stream: True} with requests.post(URL, jsonpayload, headersHEADERS, streamTrue, timeout(10, 120)) as r: r.raise_for_status() buf for raw in r.iter_content(chunk_sizeNone): if not raw: continue buf raw.decode(utf-8, errorsreplace) while \n in buf: line, buf buf.split(\n, 1) line line.strip() if not line or not line.startswith(data:): continue data line[5:].strip() if data [DONE]: return try: chunk json.loads(data) except json.JSONDecodeError: # 半包残留塞回缓冲区等下一轮 buf data: data \n buf break delta chunk[choices][0].get(delta, {}) piece delta.get(content) if piece: yield piece这段代码里有个刻意保留的细节解析失败时把内容塞回缓冲区再break。极端情况下这种情况不该发生但网络层偶发的截断确实存在多这一层就不至于把半截数据当成新事件解析出一堆乱码。另外注意iter_content(chunk_sizeNone)的写法让底层按实际收到的数据块给而不是固定大小切分可以减少拼接次数不过不同客户端库的行为有差异用iter_lines也可以但一定要自己处理解码别让库默认按某种编码解码中文内容很容易在这里被切坏。结束标记的处理也值得注意。收到结束标记后应该立刻return但有些人会继续读完整个流这没有任何意义反而可能因为服务端迟迟不关连接而卡住。更麻烦的是有些实现收到结束标记后break出内层循环外层还在继续读白白占用连接。3.3 流式下的中断断了之后到底该重来还是该接着拼流式最尴尬的情况是收到了一半内容然后连接断了。此时你有两个选择整段重来或者保留已收到的部分并尝试续写。整段重来最简单但用户会看到已经出现的文字突然被清空重写体验很差而且重复计费。续写的做法是把已收到的文本作为助手消息塞回上下文追加一句请从上次中断的地方继续让模型接着写。这招在长文生成场景很常用但要注意模型有时候会重复上一句的结尾需要在拼接时做一次重叠检测。我的经验是把已收到的内容按段落切开取最后两段作为续写锚点拼接时做一次后缀匹配重叠部分只保留一份。这套逻辑写下来不到五十行但能明显改善长文生成的中断体验。另外别忘了给流式加一个最长静默时间的监控——连续若干秒没有收到任何数据块就主动断开否则一个僵死的连接可能挂到读超时才释放。4. 参数、预算与重试把每一次调用的成本和延迟算清楚参数乱填的人通常也没算过成本。这两个问题是连在一起的温度影响输出长度分布输出长度影响费用重试策略影响总调用次数。把这三个变量放在一起看才能理解为什么调通和跑得起是两件不同的事。4.1 采样参数的实际手感温度、核采样与输出长度温度控制的是候选词概率分布的平坦程度。温度趋近于零模型几乎总选概率最高的词结果高度可复现温度拉高低概率词也有机会被选中输出更发散。实际使用中的经验值是这样抽取、分类、格式转换、代码生成这类要求确定性的任务温度给零到零点二摘要、改写给零点三到零点五创意写作、头脑风暴才需要零点七以上。有个反直觉的点是温度过低并不总是更好——在某些模型上极低温度会放大复读倾向输出卡在同一个句式里出不来这时候稍微抬一点反而更顺。核采样阈值是另一条控制发散程度的旋钮逻辑是只在累积概率达到阈值的那批候选词里采样。它和温度是有重叠的一般不建议两个同时大改。我的习惯是固定其中一个通常把核采样留在默认值只用温度做主要调节这样参数变化和效果变化之间是单向对应出了问题好归因。最大输出长度要按任务定前面说过。这里补一个计算细节如果你的任务是抽取 N 条记录每条记录的结构化输出大概 M 个 token那么上限至少给到 N×M×1.5那 0.5 是留给格式字符和模型冗余的余量。我一般会先用几条真实数据跑一遍看用量统计里的实际输出 token取最大值再乘一点五这个值比拍脑袋准得多。任务类型温度输出上限建议备注字段抽取、分类0 ~ 0.2300 ~ 600上限按记录条数×单条长度×1.5 估结构化改写0.2 ~ 0.4800 ~ 1500保留原文长度余量摘要压缩0.3 ~ 0.5600 ~ 1000先看实际输出再定上限长文生成0.6 ~ 0.8按目标字数×1.5建议配合流式与续写头脑风暴0.8 ~ 1.01000 以上需要多样性不建议低温度4.2 token 预算怎么估才不至于月底超支粗估规则中文大致一个汉字对应零点六到一个 token英文大致四个字符对应一个 token标点和换行也占。这个比例不同分词器有差异但用来做量级估算够用。保守一点直接把一个汉字当一个 token 算宁可高估。一次调用的总消耗等于输入侧加输出侧。输入侧包括系统消息、历史消息、本次用户消息输出侧就是content的长度。举例系统消息 200 字历史三轮共 900 字本次输入 1500 字输出预期 300 字。按汉字折 token 保守估计输入约 2600 token输出约 300 token单次合计约 2900 token。如果每天跑一万次就是两千九百万 token。拿这个数去对照你所用服务的计费标准能不能承受一目了然。真正让预算失控的往往不是单次贵而是你没意识到的隐性放大失败重试把调用次数放大一到三倍历史无压缩把输入侧放大两到五倍上限设太大让输出侧每次都跑满。这三件事叠起来实际花费可能是最初估算的十倍。我在项目里会加一个简单的计量器按天累计输入输出 token超过日预算阈值就告警这比月底看账单再追查有用得多。4.3 重试分级哪些错误值得重试哪些重试只是在烧钱拿所有错误一视同仁地重试是最常见也最贵的错误。按错误性质分三档处理比较合理。可重试的是网络层超时、连接重置、服务端返回的过载类状态码、以及极短时间内的限流。这类错误重试往往有效但要用指数退避加随机抖动不要在同一个时间点连续撞。退避基数一到两秒每次乘二重试三次封顶三次都失败就进失败队列等人工或定时再处理。不该重试的是请求格式错误、认证失败、模型标识不存在、参数越界这类。这些错误重试一百次结果都一样只会浪费时间和额度。这类错误应该在代码层面直接抛出同时把响应体里的错误描述完整记下来——服务端返回的错误信息通常写得很清楚只是很多人不看直接吞掉然后重试。需要特殊处理的是内容拦截和长度截断。内容拦截重试大概率还是被拦但如果你能定位到是输入里的某段触发的做一次改写再试是合理的。长度截断则应该调参数而不是原样重试。这两类在我的代码里都走独立分支打上标记进不同的处理队列。错误类别典型表现处理方式网络超时、连接中断读超时、连接被重置指数退避重试三次封顶服务端过载、限流对应状态码、明确提示稍后重试退避重试抖动加大必要时排队请求参数错误字段缺失、取值越界不重试直接抛出并记录认证失败密钥无效、权限不足不重试立即告警内容拦截输出中断、特定状态记录转人工谨慎改写后再试输出截断结束原因为长度调大上限或拆任务后重跑5. 一次返回为空的完整排查链路与上线前的自检习惯真正有用的排查方法不是知道答案而是知道按什么顺序缩小范围。下面这条链路是我在多次深夜排障中固定下来的从最外层往最内层走每一层都有一个明确的判断依据避免在东猜西猜里浪费时间。5.1 排查链路从响应状态到content为空的逐层定位第一层看 HTTP 状态码。非成功状态码说明请求根本没进到推理环节问题在传输或鉴权。这一步能排掉大概三成的故障密钥过期、请求体格式不对、被限流打回全在这里现形。很多人一上来就看content那是跳过了最外层。第二层看返回体有没有错误描述字段。有些服务商在成功状态码下也会带上错误信息尤其是流式接口中途出错时。把整个返回体打印出来看一眼比盯着某个字段猜有用。第三层看用量统计里的输入输出 token 数。输入为零说明请求体没被正确解析输入正常但输出为零说明模型没产生任何可见输出问题可能在内容策略、可能在参数上限被设成极小值、也可能在工具调用提前返回。第四层看结束原因。到这一步基本能定位方向长度截断去调上限内容拦截去查输入工具调用去走对应分支。第五层看content本身。如果前四层都正常而内容不符合预期问题才轮到提示词、模型选择和采样参数。这时最有效的手段不是反复改 prompt而是把系统消息和用户消息原样打印出来逐字读一遍。我遇到过至少三次模型不听话最后发现是自己把变量拼错了位置消息里的实际数据和预期完全不是一回事。def diagnose(resp, body): report { http_status: resp.status_code, error_field: body.get(error), usage: body.get(usage), finish_reason: None, content_len: 0, } choices body.get(choices) or [] if choices: report[finish_reason] choices[0].get(finish_reason) content (choices[0].get(message) or {}).get(content) or report[content_len] len(content) return report这段小函数没什么技术含量但它把上面五层的判断依据一次性摊平。排障时最怕的就是信息分散在日志各处看一眼少一个线索有个固定结构的诊断输出会省很多来回。5.2 日志该记什么、绝对不该记什么日志记少了排不了障记多了会出更大的问题。必须记的请求时间、模型标识、结束原因、输入输出 token 数、耗时、响应标识、错误码。建议记的消息条数、系统消息长度、是否流式、重试次数。绝对不要记的完整密钥、用户隐私原文。想复盘提示词效果的话记消息的哈希值加长度就够定位了需要原文时用哈希去对应的存储里查别把内容直接写进日志。一个很实用的细节是给每次调用加一个自己生成的追踪标识写进请求的附加字段里多数服务支持传业务侧追踪号同时写进日志。这样和服务端对账的时候你能精确指出是哪一次调用出的问题而不是拿时间段去猜。5.3 上线前值得花半天过的自检清单调用代码是否判断了结束原因并对长度截断有明确处理是否设置了连接超时与读超时两个独立参数而不是只给一个总超时是否对返回内容做了结构校验而不是直接信任解析结果重试是否有上限、是否有退避、是否区分了可重试与不可重试错误是否按天累计了 token 消耗有没有超过预算的告警多轮场景下是否评估过历史消息的压缩策略流式场景是否处理了半包、结束标记和中断续写日志里是否包含响应标识和追踪号且不含密钥与隐私原文这份清单里的每一条我都在不同项目里见过它被漏掉之后造成的具体后果。最不值当的是最后一条——日志里混进了不该有的内容事后处理起来的成本远超当初多写两行过滤代码。我个人在实际操作中的体会是调用大模型接口这件事打通只占两成工作量剩下八成全在把返回结果当成不可靠输入来处理。刚开始我也觉得写那么多判断和容错是过度设计直到有一次批量任务在凌晨静默失败了三百多条第二天才发现是某批数据的输出全部被截断而代码因为没判断结束原因把半截 JSON 当成失败之后默默跳过了。从那以后我的调用封装里有一条规定任何一次调用无论成功失败必须留下一条包含结束原因和 token 用量的结构化记录。这条规定的维护成本几乎为零但它让后面所有问题的定位时间从几小时压缩到几分钟。