AI、Determinism、Instrumentation 这三个词放到一起正好可以回答一个团队经常绕不开的问题大模型应用上线容易持续稳定运行却很难。Honeycomb 联合创始人 Charity Majors 在公开演讲和技术文章里反复强调不要把模型当成黑盒来崇拜可靠性来自工程系统对不确定性的识别、采集和控制。她有个广为人知的比喻叫 Eat Your Broccoli用来形容可观测性工作不性感、不新鲜、不容易偷懒但工程师必须做。下面会把 AI 背后的非确定性来源、埋点与可观测性的设计方式、最小落地案例和排查路径串联起来讲。读完后可以马上在自己的问答服务或 Agent 服务里补上第一层 instrumentation。1. 先理解确定性传统软件与 AI 应用的分水岭1.1 传统工程里的“确定性假设”传统软件开发中我们默认有一个非常基础的假设同样的输入加上同样的系统状态一定会得到同样的输出。这个假设看起来太基础以至于很少被拿出来讨论但工程体系几乎都建立在它之上。写单元测试时assert 的结果必须是稳定可复现的。如果同一个测试用例这次通过、下次失败第一反应是代码有并发问题或环境不一致。排查线上故障时只要拿到请求参数、日志和代码版本就可以在本地重现整条调用链路。灰度发布时新旧版本行为差异可以用 A/B 对比量化。版本回滚所以能成为最常用的止损手段也是因为旧代码加上旧配置就能恢复旧行为。这套确定性假设虽然不算绝对真理但它的确支撑了大部分工程工具和工程习惯。可以说软件工程里许多成熟的调试、测试、发布流程都是围绕“系统行为可复现”这个前提设计的。1.2 大模型应用为什么失去确定性把大模型接进系统之后确定性假设开始松动。同样是“你好请解释一下什么是可观测性”用户在不同时间调用可能拿到不同的表述。同一个问题在测试环境验证通过上线后却出现偏差。这不是偶发故障而是大模型应用的结构性特征。非确定性主要来自几个层面采样随机性模型按概率分布生成 tokentemperature、top_p等参数会影响每次输出的波动范围。即使参数完全固定多数模型服务也不保证输出完全相同。模型版本变化模型提供方升级版本后即使接口名不变行为也可能变化。很多问题不是你那侧改坏了而是上游模型版本悄悄变了。上下文漂移同样的用户问题如果 RAG 检索到的文档不同、历史消息不同、系统提示词被修改模型拿到的输入并不一样输出自然不同。并发和状态请求到达顺序、缓存命中与否、降级策略是否被触发都会改变模型实际看到的上下文。上游灰度模型服务方可能对外表现为同一个接口内部已经在灰度不同的模型或参数。这些因素叠加后之前“输入不变输出就不变”的工程直觉会失效。于是常见的现象是同一个问题生产环境表现不同本地却无法复现。1.3 失去确定性不等于“代码有 bug”这里要特别区分一点非确定性输出并不等于传统意义上的代码 bug。传统 bug 有相对清晰的因果链调用了一个空指针、少传了一个参数、配置没有加载。这类问题可以通过日志、堆栈和代码审查定位。大模型的输出波动更像是一种“输入的一部分”。你无法通过阅读源码断定模型回答得对不对也无法在本地把生产环境的行为完整重放。很多团队踩坑是因为默认模型是稳定的。一旦出现“时而正确、时而不正确”第一反应是调 prompt、调 temperature甚至重新训练模型。这些动作可能有效但没有数据支撑时更像是碰运气。真正能解决问题的思路是把不确定性当作系统输入来建模当它出现波动时我们必须有数据能回答“这次调用和上次调用到底哪里不一样”。能做到这一点靠的不是模型能力而是 instrumentation。2. “吃西兰花”Instrumentation 是 AI 工程的底线2.1 比喻的含义做不性感但必要的事Charity Majors 在可观测性社区流传很广的一个说法是 Eat Your Broccoli。西兰花作为比喻通常指那些不炫酷、不新鲜、但必须每天做的事写日志、做监控、埋点、复盘事故、保持系统可观测。大模型领域很容易让人把注意力放在花哨的能力展示上新的提示词技巧、新的 Agent 框架、新的多模态能力。相比之下给每次模型调用记录结构化日志、统计 token 成本、分析失败样本看起来平淡得多。但真正进入生产环境后平淡的数据往往是决定系统能不能维护下去的关键。这个比喻的核心判断是没有捷径。模型能力再强也替代不了“知道系统正在发生什么”的基本盘。2.2 为什么 AI 应用比传统系统更需要 instrumentation传统系统内部逻辑是可见的代码就在仓库里任何疑问都能通过读代码和调试器找到答案。大模型应用不同模型内部是一个黑盒我们只能看到输入和输出。可观测性因此成为理解系统的唯一窗口。AI 应用还引入了几个传统系统不太剧烈的问题模型调用有成本而且失败的代价不总是显式的。一次看起来正常的调用可能在 token 和延迟上消耗巨大。输出是否正确很难用状态码判断。HTTP 200 只代表调用链路没断不代表答案语义正确。调用链变得更长。RAG 需要记录检索到的文档Agent 需要记录工具调用步骤多层上下文叠加让问题更难定位。组件更新频率高。提示词、模型版本、检索策略都可能频繁变化没有记录就无法确认到底哪个变化影响了行为。这些原因决定了 AI 应用里 instrumentation 不是可选项而是定位问题、控制成本、评估质量的基础设施。2.3 一次 LLM 调用需要记录的最小字段可以把一次 LLM 调用想象成一次自定义事件。要保留哪些字段取决于后面想回答哪些问题。下面是一组比较通用的最小字段字段含义用于回答的问题request_id一次调用的唯一标识用户反馈问题时怎么定位到这条记录timestamp发生时间问题是否集中在某个时间窗口model_name模型标识不同模型的表现差异deploy_version模型部署或服务版本版本升级是否造成回归temperature / top_p采样参数参数是否被人为修改过prompt_hash提示词内容哈希前后两次输入是否真的相同system_prompt_version系统提示词版本prompt 变更是否引起行为变化context_docsRAG 检索到的文档 ID 列表上下文漂移是否导致答案不同prompt_tokens / completion_tokenstoken 使用量成本是否异常latency_ms调用耗时性能是否劣化cache_hit是否命中缓存缓存是否导致新旧答案混合status成功或失败失败率变化error_type / error_message异常信息失败时可以定位错误类型加上字段之后一次模型调用就不再只是一个难以复现的文本交互而是一条可供聚合和比较的事件记录。这是后续一切排查和评估的基础。3. 最小落地在问答服务里补上可查询的结构化日志3.1 用结构化日志代替自由文本刚开始做 instrumentation 时最容易犯的错误是写“人能看懂”的一段话2025-01-01 10:00:01 调用模型成功耗时 1.3 秒返回了很长的答案。这种日志在本地调试时够用但无法用脚本聚合。要统计失败率、P95 延迟、按用户维度聚合 token 消耗都需要结构化字段。推荐做法是每条日志输出一个 JSON 对象让日志系统、命令行工具和脚本都能解析。3.2 一个可扩展的调用包装器下面用一个 Python 示例说明思路。核心逻辑是把“调用模型”这个动作封装到一个类里在请求前、成功后、异常时分别记录结构化事件。import hashlib import json import logging import time import uuid from datetime import datetime, timezone logger logging.getLogger(llm.gateway) logger.setLevel(logging.INFO) class LLMCallRecorder: def __init__( self, model_name: str, deploy_version: str, temperature: float 0.2, max_tokens: int 1024, ): self.model_name model_name self.deploy_version deploy_version self.temperature temperature self.max_tokens max_tokens def ask( self, prompt: str, system_prompt_version: str , context_docs: list | None None, ): request_id uuid.uuid4().hex started time.perf_counter() base { event: llm_request, request_id: request_id, model_name: self.model_name, deploy_version: self.deploy_version, temperature: self.temperature, max_tokens: self.max_tokens, prompt_hash: hashlib.sha256(prompt.encode(utf-8)).hexdigest()[:12], system_prompt_version: system_prompt_version, context_docs: [d.get(doc_id, ) for d in (context_docs or [])], timestamp: datetime.now(timezone.utc).isoformat(), } try: response_text, usage self._complete(prompt) base.update( { status: ok, latency_ms: round((time.perf_counter() - started) * 1000, 2), prompt_tokens: usage.get(prompt_tokens, 0), completion_tokens: usage.get(completion_tokens, 0), total_tokens: usage.get(total_tokens, 0), } ) logger.info(json.dumps(base, ensure_asciiFalse)) return response_text except Exception as exc: base.update( { status: error, latency_ms: round((time.perf_counter() - started) * 1000, 2), error_type: type(exc).__name__, error_message: str(exc)[:200], } ) logger.error(json.dumps(base, ensure_asciiFalse)) raise def _complete(self, prompt: str): # 学习环境用固定响应验证日志格式。 # 生产环境替换成真实的模型 SDK 调用。 return 这是一个模拟响应。, { prompt_tokens: len(prompt), completion_tokens: 8, total_tokens: len(prompt) 8, }本地跑通这个示例需要一个入口脚本import logging logging.basicConfig(levellogging.INFO, format%(message)s) recorder LLMCallRecorder( model_nameprod-llm, deploy_versionv1.2.0, temperature0.2, ) text recorder.ask( 用一句话解释 instrumentation, system_prompt_versionv3, context_docs[{doc_id: doc_1001}, {doc_id: doc_1002}], ) print(text)代码里有几个点需要解释。首先request_id在调用开始时生成之后无论是成功还是失败都会写进同一条日志。这样排查时只要用户提供一条 request_id就能拿到完整的请求上下文。其次prompt_hash用哈希而不是完整 prompt。完整 prompt 可能很长不适合直接写进日志哈希之后可以快速判断两次调用是否使用同样的用户输入。最后deploy_version和system_prompt_version必须显式传入不能默认猜。模型版本和提示词版本是两个最容易被忽略的变量也是后续排查时必须依赖的字段。3.3 为什么这三个字段是排查的关键request_id、prompt_hash、版本号这三者构成了定位问题的坐标系。request_id 解决“用户说的是哪一次”。没有它只能靠时间范围猜测。prompt_hash 解决“输入是不是真的相同”。很多所谓随机问题最后发现是两次请求的用户输入本身就不一样。版本号解决“哪个环节被改动过”。模型版本、部署版本、系统提示词版本任何一个变化都可能解释行为差异。如果只保留模型输出文本这三类信息全部丢失问题就会变成一团无法拆解的模糊现象。3.4 本地验证跑一次调用看一条结构化日志运行上面的入口脚本控制台会输出类似这样的记录{ event: llm_request, request_id: 3f2b1a9c8d7e6f5a, model_name: prod-llm, deploy_version: v1.2.0, temperature: 0.2, max_tokens: 1024, prompt_hash: f4a1d8e2b3c9, system_prompt_version: v3, context_docs: [doc_1001, doc_1002], timestamp: 2025-01-01T10:00:0000:00, status: ok, latency_ms: 1320.55, prompt_tokens: 40, completion_tokens: 8, total_tokens: 48 }如果能看到一条完整 JSON 日志说明最小 instrumentation 已经生效。之后再把这个 JSON 接入日志采集、ELK、ClickHouse、Loki 或任何日志平台就能开始做聚合分析。4. 用数据排查“输出不稳定”不要急着改 temperature4.1 最常见的错误处理方式一旦收到“同一个问题答案时好时坏”之类的反馈很多人的第一反应是调参数。最常见的话是“把 temperature 调低一点吧这样更稳定。”这个动作不是没有道理但它跳过了关键一步先确认两条记录之间到底哪里不同。如果问题原因是 RAG 检索结果不稳定或者系统提示词被误改了调 temperature 只是在掩盖症状并不会解决根因。正确的顺序应该是先查数据再下结论。下面这条链路可以覆盖绝大多数输出不稳定问题。4.2 按顺序检查八个疑点排查输出不稳定时建议按以下顺序逐项检查。每一步都要在日志或 trace 数据里找到证据而不是凭感觉判断。模型版本是否变了对比两条记录的deploy_version如果不一致先统一版本再观察。采样参数是否不同对比temperature、top_p确认调用方是否有人改过配置。用户输入是否不同对比prompt_hash确认两次请求的问题是否真的相同。系统提示词是否不同对比system_prompt_version检查最近是否发布过新 prompt。上下文是否变化对比context_docs确认 RAG 检索结果是否一致。上下文漂移是输出变化的主因之一。是否命中缓存对比cache_hit如果一次命中旧缓存、一次直接调用模型结果差异就不奇怪。是否触发降级检查status和error_type确认是否因为上游超时走了备用逻辑。上游是否灰度如果以上字段全部一致但行为仍然变化考虑模型服务方内部版本灰度。这时需要扩大观察窗口并主动与平台方确认。4.3 排查项与埋点字段对应表下面这张表可以直接打印贴到工位旁边排查顺序疑点判断方式涉及字段1模型版本变了对比 deploy_versiondeploy_version2采样参数变了对比 temperature、top_ptemperature3用户输入不同对比 prompt_hashprompt_hash4系统提示词变了对比 system_prompt_versionsystem_prompt_version5上下文漂移context_docs 列表不一致context_docs6缓存命中不同cache_hit 字段不同cache_hit7触发降级status 为 error 或有 fallback 标记status, error_type8上游灰度字段全部一致但行为变化timestamp, deploy_version4.4 一个模拟排查场景假设生产环境收到反馈同一个问题用户有时得到 A 答案有时得到 B 答案。团队内部开始争论是 temperature 太高还是 prompt 写得不清楚。用 request_id 找到两条记录后先对比deploy_version都是 v1.2.0排除模型部署变化。再对比temperature都是 0.2排除参数差异。然后对比prompt_hash完全一致说明用户输入确实相同。继续对比system_prompt_version也相同。接下来看context_docs两条记录的文档 ID 列表有明显差异第一条是[doc_1001, doc_1002]第二条是[doc_1003, doc_1004]。到这里问题已经浮出水面RAG 检索结果不稳定导致模型拿到的参考材料不同答案自然不同。这个案例说明真正影响输出的往往不是采样噪声而是上下文漂移。如果没有 instrumentation团队会浪费大量时间在调 prompt 和调参数上最后仍然找不到根因。5. 学习环境与生产环境的 instrumentation 要求5.1 学习环境先让一次调用看得见如果只是学习大模型 API、写一个本地测试脚本不需要一上来就上完整的可观测性平台。但最低要求应该做到每条日志使用 JSON 结构而不是自由文本。至少记录 request_id、模型名、耗时、status。本地把日志打印到控制台即可方便复制和比对。学习环境的主要目标是形成“先记录、再判断”的肌肉记忆。不要因为项目小就跳过埋点否则到了生产环境会非常被动。5.2 生产环境从日志走向 trace 和评估闭环生产环境要求会高一个量级。结构化日志只是起点至少要补齐以下能力Trace 贯穿让一次用户请求从 API 入口、RAG 检索、模型调用到工具调用形成完整链路。OpenTelemetry 是通用做法可以在 HTTP 请求层透传 trace_id。评估打点定期把线上请求抽样后交给评估集或 LLM 评估打分记录分值变化。成本监控按 request、用户、功能维度聚合 token 消耗和调用延迟。告警当失败率上升、P95 延迟超过阈值、token 成本出现尖峰时能及时收到通知。回归测试固定一批黄金问题集每次升级模型或改动 prompt 时跑一遍对比输出质量和成本。5.3 生产环境发布前检查清单上线一个新的模型服务或 Agent 功能之前建议逐项确认检查项说明是否就绪模型版本固定使用明确版本号禁止默认滚动升级是 / 否采样参数固定temperature、max_tokens 统一配置是 / 否prompt 版本化每个提示词有版本号变更可追溯是 / 否request_id 透传API 入口生成并传递到内部调用是 / 否结构化日志关键调用记录 JSON 事件是 / 否异常降级记录fallback 路径也有日志是 / 否token 成本统计能按用户、功能聚合成本是 / 否评估集准备有至少一组黄金问题用于回归是 / 否缓存策略明确缓存命中行为可观测是 / 否告警规则配置失败率、延迟、成本有阈值告警是 / 否这份清单不要求一次全部完成但它可以帮助团队判断系统离“可维护”还有多远。5.4 是否一定要自建可观测平台不一定。刚开始可以先只使用结构化日志加命令行工具例如用grep、jq分析本地日志文件。数据量上来之后再接入开源方案或商业可观测平台。关键不是工具多先进而是数据结构统一、字段完整。有了干净的结构化数据迁移到任何平台的成本都会很低。注意不要先选平台再定日志格式。先把日志结构和字段定义好后面接任何平台都只是导入问题。6. 常见坑与可复用工程习惯6.1 三个最容易踩的坑问题现象常见原因检查方式处理建议用户报错时无法定位是哪次调用只记录了模型输出文本没有记录请求参数和时间戳看日志里是否包含 request_id 和 model_name补全请求参数、时间戳、唯一标识不知道线上跑的是哪个 prompt 版本直接改代码里的字符串或控制台里的 prompt检查 system_prompt_version 字段是否存在且会随改动更新把 prompt 模板化每次改动递增版本号接口返回 200 但答案明显不对缺少输出校验和评估机制看是否有输出 schema 校验、评估打分记录对输出做结构化校验再配合黄金数据集评估第一个坑是“只记录结果不记录上下文”。日志里只有一句话“回答太差”没有模型版本、没有参数、没有完整 prompt 哈希事后根本无法复现。第二个坑是“prompt 散落在各处”。有人把系统提示词写在代码里有人在调试台复制粘贴有人直接在请求体里临时改。一旦线上出问题没人能说清楚当前跑的是哪个版本。推荐做法是统一存成配置文件或专门的 prompt 管理模块并强制写入版本号。第三个坑是“把 200 当成功”。HTTP 状态码只能说明服务没有崩溃不能说明回答语义正确。对于有固定结构的输出比如 JSON、分类标签、实体列表必须做 schema 校验并记录校验失败事件。对于开放式问答则要依赖评估集抽样打分。6.2 值得长期坚持的工程习惯以下习惯看起来琐碎但长期价值很高每次模型调用都生成 request_id并尽可能透传到客户端用户反馈时直接要这个 ID。prompt 的任何改动都必须伴随版本号禁止静默替换。固定模型部署版本不默认跟随上游自动升级。缓存命中事件单独记录不要把缓存结果和实时调用混在一起分析。对模型输出做结构校验校验失败时记录错误原因。token 消耗按请求、用户、功能三个维度聚合便于成本归因。每次上线变更都要问一句如果这次改动出问题哪份数据能让我看出来6.3 不要追求绝对确定要追求受控的不确定很多团队试图通过调低 temperature 来消灭所有随机性这其实是对确定性的误解。大模型应用的目标不是“每次输出完全相同”而是“输出在可接受范围内波动”。要实现这一点靠的是工程约束用结构化输出约束格式比如强制 JSON、强制枚举值。用 RAG 检索质量约束上下文确保模型拿到的内容稳定、相关。用失败降级策略兜底当输出校验失败时重新请求或返回固定文案。用评估集持续监控发现质量下降就回滚版本。这些手段都不能消除非确定性但可以把不确定性的影响控制在业务可接受的范围。这才是 AI 工程里更务实的确定性目标。6.4 给新手的练习建议如果正在学习 LLM 应用开发可以从一个小练习开始把文章里的LLMCallRecorder接入一个简单问答服务然后连续调用 100 次同一个问题。之后用脚本统计四个数字失败率、平均延迟、P95 延迟、平均 token 消耗。再把其中一次异常输出的日志单独拿出来尝试用request_id还原这次调用发生了什么事。最后把系统提示词改一个版本用同样的练习跑一遍比较结果变化。这个练习只需要一个简单的 Python 脚本和一份完整日志但足够理解 instrumentation 的基本工作方式。7. 从“吃西兰花”看 AI 工程的长期主义7.1 核心判断确定性不是模型的属性而是工程的产品回顾整条主线可以发现一个核心判断AI 应用的稳定性不是靠选择某个“更确定的模型”获得的而是靠工程系统采集数据、约束行为、持续验证建立起来的。模型是非确定的这是底层特性。工程能做的是把每一次调用变成可查询的事件把上下文变化变成可比较的字段把质量下降变成可触发的告警。当这些机制建立起来之后系统行为才是可控的。这个过程的本质就是用工程手段对抗模型的不确定性。7.2 下一步可以扩展的方向沿着这篇文章的思路有四个方向可以继续深入从日志走向 trace用 OpenTelemetry 把一次请求在 API 层、检索层、模型调用层之间的链路串起来形成完整追踪。从单轮问答走向 Agent 场景Agent 有工具调用、多步推理、中间结果每一步都可能是错误来源每一步都要埋点。Agent 的 instrumentation 比单轮问答复杂得多也更有必要。从手动评估走向自动化评估建立黄金问题集每次模型升级、prompt 改动、检索策略调整时自动跑评估用分数和成本辅助决策。从成本统计走向成本治理在 token 消耗、缓存命中率、模型调用次数之间建立指标避免上线后成本失控。7.3 一个可以立即用上的方法如果现在只记住一件事建议记住这句检查方式每次改动模型、prompt 或检索策略之前先问一句话——“如果这次改动出问题我有没有数据能看到”如果没有说明 instrumentation 还没到位。先补数据再改功能。这个习惯听起来不够酷但正是 Charity Majors 说的 Eat Your Broccoli 真正在工作中的样子决定一个 AI 应用能否长期维护的往往不是模型多聪明而是当它出错时你能不能快速看明白它是怎么错的。