
在大语言模型LLM项目从 Demo 走向生产时讨论最密集的问题往往不是模型选型而是怎么把结果稳定下来。很多团队提出的问题可以概括为How do you develop more deterministic LLM pipelines? 中文语境里就是如何开发更确定性的 LLM 流水线。这个问题至少包含两层含义一是同样的输入是不是每次都能得到同样结构、同样语义的输出二是当中间步骤依赖模型结果时系统能不能保证后续路由、入库、通知等操作不会因为一次随机波动而错乱。这里的关键词是 deterministic LLM pipelines而不是“完全禁止随机”。大语言模型本身是概率模型想要在二进制层面实现“相同输入必定相同输出”通常不现实。更实际的目标是让流水线的每一步都有明确边界输出在格式上可以被机器解析在语义上可以被业务规则校验在异常时可以走回退或重试在版本更替时可以通过回归测试发现漂移。下面从确定性目标、采样参数、结构化输出、校验重试、缓存评估和排错路径几个角度展开。1. 先理解确定性 LLM 流水线的目标不把“完全一致”当作唯一标准1.1 用户真正关心的确定性语义稳定、格式稳定、流程可重复同一个用户问题先问一次模型得到“查询天气”再问一次得到“获取天气”对业务系统来说通常是可以接受的。两次返回都符合枚举约束都属于同一个 intent下游动作就不会发生歧义。真正不能接受的是第一次返回合法 JSON第二次返回一段带解释文字的 Markdown或者第一次把地点抽取为“上海市区”第二次把同一个值拆成“上海”“市区”两个字段。所以“确定性”可以拆成三个等级格式确定性输出必须是合法 JSON、YAML、代码块或纯文本不能混入额外解释。语义确定性关键词、枚举值、实体内容在业务含义上保持一致。流程确定性相同输入在整个流水线中走相同分支不会因为模型的偶发抖动进入不同的下游路径。在设计流水线时通常先保证格式确定性再保证语义确定性最后通过回归测试观察流程确定性。1.2 把不确定性来源拆到输入、模型和流程三层很多人调了半天参数才发现不稳定并不是模型采样造成的而是上游输入本身不稳定或者下游代码对输出过于敏感。把问题分层之后才能知道自己到底在优化哪一层。表格确定性流水线中三层不确定性的来源层次典型变化来源对流水线的影响可控程度输入层用户输入大小写、标点、同义词、few-shot 样例顺序、Prompt 模板改版模型可能给出不同分类结果或抽取结果相对可控需要做输入归一化和 Prompt 版本管理模型层temperature/top_p 随机采样、服务端推理引擎升级、模型版本切换、量化方式输出候选词分布变化甚至 JSON 结构变化部分可控可通过参数约束但不能完全锁定流程层调用超时后重试、解析异常、下游接口幂等性、缓存失效同一次业务请求可能被执行多次或命中旧结果完全可控需要靠工程手段兜底实际生产环境里模型层的不确定性往往最容易背锅但真正导致线上问题的多数是输入层和流程层没有做约束。1.3 先给流水线定一个“确定性目标清单”动手写代码前建议先用一份清单描述“怎样的输出算确定”。不要只写“输出稳定一点”而要写可验证条件例如输出必须是单个合法 JSON 对象外层不能有解释文字。intent 字段必须属于 retrieve、summarize、chat 中的某一个。用户提到的城市必须被填到 city 字段没有提及时不能凭空构造。解析失败后最多重试两次重试仍失败时走 fallback 分支。Prompt 或模型一有变更必须能在一个固定测试集上对比指标。这类清单能避免把大量精力花在“让两次输出逐字一致”上而忽略真正影响系统可靠性的格式、枚举和业务规则。2. 采样参数与结构化输出先用配置框住随机性2.1 关键采样参数各自控制什么对大模型服务有了解的人都会先想到设置 temperature0。这个做法方向正确但需要知道它限制的是“概率分布的锐利程度”。temperature 越低高概率 token 被选中的可能性越大temperature0 通常会让输出更偏向模型认为最可能的序列但它不等于数学上的确定性因为服务端可能存在批处理、量化扰动、并发调度等额外因素。常见采样参数对确定性的影响可以参考下表参数作用调低后的表现在确定性流水线中的建议temperature控制概率分布平滑程度输出更保守、更可预测结构化任务优先设置为 0top_p只在累计概率达到阈值的候选中采样top_p 越低候选越集中可以用 0.1 到 0.3 做二次限制max_tokens限制最大生成长度避免超长输出但过短会截断根据任务估算合理长度frequency_penalty降低重复 token 的得分减少复读但会影响语义分布抽取和分类任务通常不加presence_penalty鼓励出现新主题会让输出发散确定性任务默认保持较低值seed让部分服务端尽力复现同一次采样不是所有模型都承诺稳定只能作为辅助不能作为唯一保障有的模型提供 seed 参数如果返回结果中包含 fingerprint 之类信息可以用它判断请求是否命中了同一套服务端版本。但不要写成“设置 seed 后肯定一致”这种假设模型推理引擎升级、模型版本切换都会让同一 seed 产生不同输出。在代码里设置采样参数时最好把参数作为流水线配置的一部分显式传入而不是依赖服务端默认值。不同厂商、不同版本 SDK 的默认值并不完全一样显式写清楚可以减少环境差异带来的干扰。response client.chat.completions.create( modelmodel_name, messagesmessages, temperature0, max_tokens512, )这段代码只适合说明整体思路。实际接入时请先确认你使用的 SDK 是否支持这些参数以及模型版本是否支持 temperature0因为部分模型对某些参数有值域限制。2.2 用结构化输出约束模型必须返回合法 JSON只靠 temperature 压低随机性还不够。模型仍然可能在不该解释的地方补充一句“好的我来分析一下”或者在 JSON 外面加上 Markdown 代码块。为了减少这类问题应该尽量让模型输出遵循明确的结构化格式。使用 OpenAI 兼容接口时可以通过 response_format 要求模型输出 JSON 对象messages [ {role: system, content: 你只负责把用户问题解析成 JSON不要输出其他内容。}, {role: user, content: user_query}, ] response client.chat.completions.create( modelmodel_name, messagesmessages, temperature0, response_format{type: json_object}, ) content response.choices[0].message.content如果服务端支持 json_schema也可以把更严格的 schema 直接作为参数传入。schema 的作用等于告诉模型“你只能在规定的字段里填值”。例如{ name: intent_result, strict: true, schema: { type: object, properties: { intent: {enum: [retrieve, summarize, chat]}, cities: {type: array, items: {type: string}} }, required: [intent, cities], additionalProperties: false } }不是所有模型和接口都支持严格的 json_schema。对于不支持的模型至少要在 System Prompt 中写清“只返回 JSON”并在下游代码中做一次 JSON 解析和字段校验。不能假设模型一定会遵守。2.3 提问方式要尽量让模型“选择而不是生成”同样是判断用户意图开放式 Prompt 和限制式 Prompt 的稳定性差别很大。不推荐的方式请判断用户意图并返回结果。推荐方式用户问题{user_query} 请从以下意图中选择一个不要增加额外含义 - retrieve用户想查询资料 - summarize用户想总结内容 - chat普通对话 要求 1. 只返回 JSON 对象。 2. intent 只能是上面三者之一。 3. 如果用户没有提到城市cities 返回空数组。 JSON 示例{intent: retrieve, cities: [北京]}这条 Prompt 把模型的自由生成空间压缩到了最小。它明确了候选值、字段含义和失败时的默认值。模型不需要创造性地解释“这个用户大概是想做什么”只需要在几个封闭选项里做选择。这个思路比单纯调低 temperature 更能提升流水线的整体确定性。3. 引入校验层和修复重试把模型输出变成受控数据3.1 为每一步定义中间数据模型LLM 流水线通常由多个步骤组成。比如先做意图识别再做参数抽取再决定调用哪个工具。如果不给每个步骤定义中间数据模型下游代码就会散落着大量的data_xxx字段名和类型判断改一个字段名就到处出错。推荐用 Pydantic 或 dataclass 把中间结果固定下来。Pydantic 的好处是既能做反序列化也能在字段类型、枚举和嵌套结构上做校验。from typing import Literal from pydantic import BaseModel, Field class IntentResult(BaseModel): intent: Literal[retrieve, summarize, chat] cities: list[str] Field(default_factorylist, description用户提到的城市列表) confidence: float Field(default0.0, ge0.0, le1.0)这里的Literal直接把 intent 限制在三个值里比在业务代码里用 if-else 判断字符串可靠得多。模型一旦输出searchPydantic 会立刻抛校验错误避免了错误值一路传到数据库或路由层。3.2 模型输出先过校验再进入业务逻辑下面这一段是流水线里最核心的拦截点。它做的不是“把模型返回的字符串转成对象”而是“不合格的输出直接算失败并准备触发修复或回退”。import json from pydantic import ValidationError def parse_intent(raw_content: str) - IntentResult: # 保险做法先去掉可能的 Markdown 代码块标记 cleaned raw_content.strip() if cleaned.startswith(): cleaned cleaned.strip() if cleaned.startswith(json): cleaned cleaned[4:] data json.loads(cleaned) return IntentResult.model_validate(data)校验失败时要把失败原因记录下来而不是直接把原始文本传给下一步。常见的失败包括JSON 外层有多余解释文字。intent字段是check weather不符合Literal约束。cities字段不是数组而是用逗号拼接的字符串。模型没有输出任何内容raw_content为空字符串。在实际项目中可以把错误信息拼接成一条新的反馈 Prompt让模型基于前一次输出做修复。这一步能显著提高最终成功率但也要防止无限重试。3.3 修复重试与兜底策略修复重试的核心逻辑是把校验错误反馈给模型要求它只修改不符合要求的位置不要重写整个答案。def run_intent_step(user_query: str, max_retries: int 2) - IntentResult: last_error for attempt in range(max_retries): messages build_intent_messages(user_query, last_error) content call_llm(messages) try: return parse_intent(content) except (json.JSONDecodeError, ValidationError) as exc: last_error f第 {attempt 1} 次解析失败{exc} logger.warning(last_error) raise IntentParseError(last_error)这里的经验是重试次数不要设置太多一次业务请求最多 2 到 3 次修复即可。如果重试后仍然失败直接走人工审核或降级分支比反复调用模型更可控。每次重试都要把上一次错误反馈给模型否则模型大概率会重复同样的结果。注意重试只能解决结构性问题不能保证语义一定正确。比如模型把用户意图从 retrieve 误判为 chat校验器是看不出来的这要依赖后续的评估集和业务后验。4. 靠输入版本、缓存和重试策略控制整条链路波动4.1 Prompt、模型和参数一起作为流水线版本生产环境里经常出现“昨天还好好的今天突然变了”。一个常见原因是有同事在 Prompt 里多加了一个示例词或者把模型从旧版本换成了新版本。这类变更如果不记录排查起来极其痛苦。可以把一次 LLM 调用的关键信息封装成一个配置对象让它成为整条流水线的“版本号”。dataclass(frozenTrue) class LLMStepConfig: prompt_version: str model: str temperature: float 0.0 max_tokens: int 512 response_format: str json_object每次调用 LLM 时使用相同的prompt_version和 model 作为日志字段。Prompt 模板文件放入 Git发布新模板时同步修改版本号。不要在同一个版本里混入多套未命名的 Prompt 字符串。4.2 缓存要做键规范化避免命中旧结果缓存是控制重复调用波动最直接的手段。相同业务参数重复请求时如果缓存里已经有结果就不需要再次调用模型也就不会引入新的随机变量。但缓存键如果不规范效果会很差。用户输入“上海 天气”和“上海今天天气”可能都被识别成 retrieve如果只把原始字符串作为键就无法共享缓存。一种做法是在意图识别后用“步骤名 Prompt 版本 模型名 归一化输入”作为缓存键import hashlib def cache_key_for_intent(user_query: str, cfg: LLMStepConfig) - str: normalized .join(user_query.split()).lower() raw_key fintent|{cfg.prompt_version}|{cfg.model}|{normalized} return hashlib.sha256(raw_key.encode(utf-8)).hexdigest()如果项目使用 Redis 或 Memcached还要考虑缓存过期和淘汰策略。Prompt 迭代后如果不主动让旧缓存失效用户会一直拿到旧结果反而让人觉得“系统没更新”。建议在流水线版本号变化时同时更换缓存前缀。4.3 超时和状态码决定如何重试不要一刀切很多确定性流水线会死在重试策略上。网络抖动一次就无限重试或者无论什么错误都立刻重试都会放大不确定性。推荐的分类方法是错误类型常见现象处理方式限流/负载过高HTTP 429、请求排队使用指数退避重试配合 jitter 避免同一时间集中重试服务端暂时不可用HTTP 500、502、503可以重试 2 到 3 次请求参数错误400、schema 校验失败不重试检查参数是否符合模型要求业务校验失败JSON 解析失败、枚举不匹配让模型基于错误信息修复一次超时request timed out先判断模型是否可能已生成结果避免重复副作用对于带副作用的流水线比如“抽取成功后发送工单”不能只靠调用模型后的 HTTP 状态判断是否成功。如果客户端超时但服务端实际已经处理盲目重试可能触发两次工单。更稳妥的做法是让下游接口幂等或者在调用前生成一个 request_id重试时沿用同一个 request_id。5. 评估与观测把“确定性”变成团队可维护的指标5.1 为每种任务建立回归样例集只在本地手工测两个例子很难看出 Prompt 改版到底让流水线变稳定还是变差。每个 LLM 流水线都应该维护一组“黄金输入”里面包含正常的用户问题、边界情况、模糊表达和容易误判的反例。测试代码不能只检查某个模型的直接输出要检查流水线经过解析、校验、重试后的最终结果GOLDEN_CASES [ {query: 上海明天天气怎么样, expected_intent: retrieve, expected_cities: [上海]}, {query: 帮我总结这篇文档, expected_intent: summarize, expected_cities: []}, {query: 你好, expected_intent: chat, expected_cities: []}, ] def test_deterministic_pipeline(): for case in GOLDEN_CASES: result run_intent_step(case[query]) assert result.intent case[expected_intent] assert result.cities case[expected_cities]这里不建议把城市列表的顺序写死因为 LLM 返回数组顺序不一定是稳定的。业务上“保存一个城市集合”如果对顺序不敏感用 set 做断言更合理。5.2 每次改动都记录原始响应而不是只记录解析后对象排查“为什么这次输出不对”时最常见的问题是日志里只有IntentResult(intentretrieve, cities[北京])没有模型最初的原始字符串。于是你不知道模型是直接返回了正确 JSON还是在一次奇怪输出之后被修复逻辑救回来。建议给每次模型调用记录一段结构化日志至少包含request_id、Pipeline 版本、Prompt 版本。model、temperature、max_tokens。原始 Prompt 或脱敏后的 Prompt。模型原始返回内容。解析是否成功、第几次重试成功。最终落到业务代码的中间对象。JSON 日志示例如下{ request_id: 6f1a92b0-1c2e-4f3a-9d2b-7a6e1f8c9d30, pipeline: user_intent, prompt_version: 2025.04.01, model: gpt-4o-mini, attempt: 1, raw_response: {\intent\: \retrieve\, \cities\: [\上海\]}, parse_success: true, final_intent: retrieve }有了这份日志再面对“用户说某句话结果不稳定”的反馈就能直接筛出同一个 Prompt 版本下的全部原始响应对比哪些请求发生了解析失败、哪些走的修复重试。5.3 上线前对比评估集上的通过率Prompt 改版、模型升级、温度参数调整之前先在同一个黄金数据集上跑一次回归。对比的不是“看起来好不好”而是三条硬指标格式通过率原始输出无需修复就能通过 JSON 解析的比例。校验通过率经过重试逻辑后最终能通过 schema 校验的比例。业务正确率结果与人工标注一致的占比。单条 Prompt 修改可能让个别 case 变好却让另一些 case 变差。只有固定评估集才能把这个回归过程暴露出来。评估集不需要很大每个意图或分支覆盖 20 到 50 条代表性输入通常就能发现大部分问题。6. 常见报错与排查链路6.1 踩过的坑与对应处理思路问题现象常见原因推荐处理temperature0 后结果仍不稳定服务端模型版本切换、推理引擎升级、上游 Prompt 未固定显式固定 prompt_version 和 model并用评估集观察漂移模型不返回 JSON而是一段解释未使用结构化输出参数或 System Prompt 不够强硬开启 response_format要求只输出 JSONJSON 解析成功但缺少字段只要求“输出 JSON”没有定义字段必填规则使用 json_schema 或 Pydantic 严格校验必填字段schema 校验失败后反复调用每次都报同样错误没有把上一次错误反馈给模型在重试 Prompt 中带上具体报错信息缓存命中了旧 Prompt 的老结果Prompt 版本升级后没有让缓存失效把 prompt_version 放进缓存键请求报超时重试后产生重复业务下游接口没有做幂等使用 request_id重试时沿用同一个 ID6.2 从现象倒推排查链路当一条 LLM 流水线出现质量问题时可以按下面的顺序排查固定复现输入确认输入文本在代码中没有被意外拼接。查看原始日志里的 Prompt确认运行时 Prompt 和设计时 Prompt 是否一致。确认模型参数是否被正确传入temperature、response_format 是否生效。查看原始返回内容判断问题出在“模型输出”还是“我们解析不准”。如果解析报错把报错信息原样带入提示词重试观察能否修复。如果重试后仍然失败检查是否命中缓存、是否走了降级分支。如果单条成功但整体不稳定回到黄金评估集跑一次回归。如果看到类似llm request failed: provider rejected the request schema or tool payload的报错先检查请求里携带的 JSON schema 是否超出模型支持范围字段名是否包含不合规字符以及是否误把工具调用参数传给了不支持工具的模型。如果看到类似llm request timed out的报错优先检查网络状态、服务端响应时间和 max_tokens 设置。如果模型生成长文本较长而 max_tokens 设置得过小也容易让服务端在接近超时时被中断。超时接口要尤其注意幂等避免重试造成重复副作用。7. 最小可运行的确定性流水线骨架7.1 代码骨架怎么组织把前几节提到的方法整合成一个可运行骨架。这个骨架不依赖具体厂商实现重点展示“参数固定 - 调用模型 - 校验 - 反馈重试 - 缓存”这条链路。import hashlib import json from dataclasses import dataclass from typing import Callable dataclass(frozenTrue) class StepConfig: prompt_version: str model: str temperature: float 0.0 max_tokens: int 512 class LLMCallError(Exception): pass class ValidationFailedError(Exception): pass def call_llm(cfg: StepConfig, messages: list[dict]) - str: # 示例结构此处应替换为实际客户端的调用方式 response client.chat.completions.create( modelcfg.model, messagesmessages, temperaturecfg.temperature, max_tokenscfg.max_tokens, response_format{type: json_object}, ) return response.choices[0].message.content def cache_key(cfg: StepConfig, user_text: str) - str: normalized .join(user_text.split()).lower() raw f{cfg.prompt_version}|{cfg.model}|{normalized} return hashlib.sha256(raw.encode(utf-8)).hexdigest() def run_step( cfg: StepConfig, user_text: str, build_messages: Callable[[str, str], list[dict]], validator: Callable[[str], object], max_retries: int 2, enable_cache: bool True, ) - object: key cache_key(cfg, user_text) if enable_cache: cached get_from_cache(key) if cached is not None: return cached last_error for attempt in range(max_retries): try: content call_llm(cfg, build_messages(user_text, last_error)) result validator(content) if enable_cache: set_cache(key, result) return result except (json.JSONDecodeError, ValidationError) as exc: last_error f第 {attempt 1} 次失败{exc} print(last_error) raise ValidationFailedError(last_error)这个骨架把容易变化的部分留成参数build_messages负责构造 Promptvalidator负责解析和校验。业务代码只需要关心最终拿到的对象是否通过校验不需要散落各种对原始字符串的猜测。7.2 运行方式和检查点本地验证时可以写这样一段代码cfg StepConfig(prompt_version2025.04.01, modelmodel-name, temperature0) def build_messages(user_text: str, last_error: str) - list[dict]: system 你只输出 JSON不输出解释。 if last_error: user_content f上一次输出不符合要求。请修正。错误{last_error}\n原始问题{user_text} else: user_content user_text return [ {role: system, content: system}, {role: user, content: user_content}, ] result run_step(cfg, 上海明天天气怎么样, build_messages, parse_intent) print(result)检查点主要有三个模型返回内容是否能被 validator 直接接受。第一次失败时第二次是否携带了 error 信息。相同输入连续运行两次是否都得到同类型业务对象。7.3 从 Demo 到生产还需要补什么上面的代码只解决了本地示范问题。生产环境还需要额外考虑以下几点密钥放到环境变量或配置中心不写进代码仓库。调用模型时增加客户端超时时间避免挂死。获取合同、法律、医疗等高风险场景时需要人工审核分支。记录调用量、失败率、重试率、缓存命中率等指标。模型输出需要脱敏后才能写日志。模型版本升级前先跑回归测试确认字段语义没有漂移。8. 更确定的第一步不是调参数而是定评判标准8.1 落地前先确认业务需要哪种确定性不同业务对确定性的要求完全不同。批量文本分类可以在少数失败样本上接受人工修正但财务单据抽取不允许字段缺漏客服问答可以允许不同措辞但工具调用绝对不能把“查询订单”误判成“取消订单”。在做任何参数调优之前先回答三个问题这个步骤的下游系统能接受哪些值这个字段缺失时业务要拒绝还是降级同一输入重复执行时产生不同业务结果是否可接受如果团队对这三个问题没有共识调出来的确定性也只是“某次测试感觉稳定”而不是“这套流水线在约束下可预期”。8.2 发布不可变的确定性检查清单每次修改 Prompt、模型版本或重试逻辑前可以对照下面的清单过一遍输入是否做了大小写、空白符和必要转义的归一化。System Prompt 是否明确要求只输出结构化 JSON。是否限制了枚举值范围和必填字段。解析后是否经过 Pydantic 或等价 schema 校验。校验失败时是否有反馈式重试并且重试次数有限。Prompt 版本、模型名、参数是否写进日志。相同输入是否可以通过缓存避免重复调用。超时重试是否配合幂等 request_id。是否维护了覆盖正常和异常分支的黄金测试集。模型变更后是否对比过格式通过率、校验通过率和业务正确率。确定性不是靠某一个参数、某一次 Prompt 优化实现的而是整条流水线的产物。真正有效的路径是先定义业务可以接受的“确定性范围”然后用结构化输出、校验、重试、缓存和回归测试把模型的行为约束在这个范围里。这样即使底层的 LLM 仍然有概率性上层的业务系统也能保持稳定、可观测、可回退。