当一个客服机器人刚上线时,你收到用户投诉:“它明明两天前回答得很好,今天同样的问法,结果完全跑偏了。”于是你尝试复现,但用同样的提示词试了三次,三次答案都不一样。你开始怀疑是模型升级、上下文被污染、Embedding 检索到了错误片段,还是用户输入被格式化时出了问题。最可怕的是,你连“它到底当时看到了什么”都不知道。
这是 AI 应用工程化最典型的一道坎:模型本身是概率性的,而你的系统却在一个又一个环节叠加着不确定性。如果不从工程上把每次调用的上下文、输入、输出、链路和成本完整记录下来,AI 应用一旦进入生产环境,就几乎等于“睁眼瞎”。
Charity Majors 是她长期研究可观测性、分布式系统调试的工程师。她在这个话题上的态度一贯鲜明:AI 应用应该被当成分布式系统来观测,而不是当成一个黑盒模型来供奉;那些枯燥、不性感但必须做的可观测性工程,就是科技行业需要吃下的“西兰花”。本文就从确定性、仪器化(Instrumentation)和 AI 可观测性的关系出发,讲清楚为什么 AI 应用比传统软件更需要观测,并给出一个可直接上手的最小可观测性示例。
1. 这篇文章真正要解决的问题
很多人第一次接触大模型时,最强烈的感受是“惊喜”;到了第二次,开始做工程化,感受就会变成“失控”。
失控来自三个层面:
- 输出不可复现:同样的问题,模型可能给出不同答案,而且说不清变化来自采样、模型版本、提示词、上下文、检索结果还是外部工具。
- 链路不可追踪:一次 Agent 调用可能涉及大模型、向量数据库、外部 API、工具函数。任何一环出错,错误都会被模型“包装”成一段流畅但错误的话。
- 成本与质量很难归因:如果每个请求花费 0.02 美元,10 万请求就是一笔不小的成本。到底是哪个用户、哪个 prompt、哪类上下文触发了高 token 消耗?没有观测数据就无法回答。
这些问题之所以棘手,根本原因不是“模型不够聪明”,而是观测能力没有跟上。传统软件靠日志、指标、链路追踪就能定位问题,但 AI 应用多了一层无法用日志简单表达的“语义信息”:模型到底读了什么、生成了什么、为什么选了这个结果。要让 AI 应用可调试、可评估、可回滚,就必须补齐这层观测。
因此,这篇文章要解决的核心问题不是“怎么调 prompt”,而是:如何为 AI 应用建立一套可观测性体系,让概率性的模型行为变得可以被分析、被比较、被控制。
如果你正在做 AI 应用开发、Agent 平台、大模型集成,或者在负责承载 AI 应用的平台基础设施,这篇文章适合你。读完你会明白:Determinism(确定性)不是靠“把 temperature 设为 0”就能实现的,真正可靠的确定性来自 instrumentation(仪器化)所建立的数据基础。
2. 核心概念:Determinism 与 Instrumentation
2.1 确定性在传统工程中意味着什么
在传统软件开发里,确定性是一条默认的工程假设。
你写一个函数,输入2 + 2,输出就应该永远是4。如果某天输出变成了5,这被称为 Bug,它通常可以复现、可以在调试器里单步执行、可以通过加日志缩小范围、最终通过代码修复。
这种确定性的另一面是可解释性:栈、日志、事务 ID、异常堆栈、数据库查询记录,这些信息构成了一张因果关系网。出了问题,顺着网就能找到源头。
在分布式系统中,确定性被弱化了一些:网络可能超时,队列可能乱序,节点可能宕机。但我们仍然可以用请求 ID、链路追踪、结构化日志来重建“这次请求的行为轨迹”。换句话说,传统分布式系统通过观测重新获得确定性。
2.2 仪器化为什么是可观测性的前提
Instrumentation,中文常翻译为仪器化或插桩,指的是在软件中主动埋入数据采集点:打印日志、记录指标、生成调用链。它是一切可观测性系统的前提。
没有 instrumentation,就没有数据;没有数据,就没有可观测性。这听起来像废话,但很多团队在实际操作中顺序是反的:先搭监控大屏,再讨论告警,最后才考虑埋点。结果大屏上只有 CPU、内存这种基础设施指标,业务和模型指标全空白。
正确的顺序是:先埋点,再采集,再可视化,再告警。这也是 Charity Majors 和 Honeycomb 团队反复强调的“telemetry-driven development”:把遥测数据当作开发的一部分,而不是运维的附属品。
2.3 AI 应用改变了什么:从确定性到概率性
大模型给工程带来的最大变化,是把“确定性”从默认假设变成了需要额外设计的目标。
传统函数是输入决定输出;大模型输出的是概率分布上的采样结果。即使 temperature 设为 0,很多模型仍可能因为算子实现、batch 策略、量化方式而出现微小变化。更复杂的是,AI 应用的输出不仅依赖模型,还依赖系统给它构建的上下文:检索到的文档、Agent 上一步的工具返回、多轮对话历史的截断方式。
可以把 AI 应用想象成一个极度依赖外部环境的“演员”:同一个剧本(prompt),不同化妆间(context)、不同对手(工具)、不同场次(模型版本)都会导致台词的细微变化。你要想“复现”一场表演,必须完整记录现场所有变量。
下面的表格可以更直观说明这种差异:
| 维度 | 传统软件 | AI 应用 |
|---|---|---|
| 输入 | 结构化、有明确边界 | 自然语言、上下文、向量检索结果 |
| 输出 | 稳定、可预期 | 概率性、不可完全复现 |
| 错误形态 | 异常、堆栈、错误码 | 流畅但错误的文本、语义偏差 |
| 调试方式 | 断点、日志、堆栈 | 需要记录模型输入输出、检索结果、工具调用 |
| 可复现性 | 高 | 低,需要大量上下文与参数才能逼近 |
| 成本特征 | 相对固定 | 随 token、模型版本、上下文长度波动 |
这张表的结论很简单:AI 应用的可观测性不能套用传统软件的“日志 + 指标”模式,需要增加专门针对模型调用和上下文的追踪能力。
3. 为什么说可观测性是 AI 时代的“西兰花”
很多人用“吃西兰花”比喻那些正确但无聊的事情。健康和好吃常常是矛盾的,工程上也一样:做新模型评测很刺激,搭一个新 Agent 框架很兴奋,但给系统加埋点、统一 trace 规范、做上下文脱敏,确实没有那么多“成就感”。
Charity Majors 在讨论 AI 与可观测性时的核心态度,就是强调这个“吃西兰花”的时刻。她并不是说不要研究 AI,而是说:如果你要把 AI 放进生产系统,就必须用生产系统的标准来要求它。生产系统的标准不是“demo 能跑通”,而是“故障能定位、变更能评估、成本能归因、行为能解释”。
但这个逻辑很容易被误解。
一种常见误区是:把 prompt 调优当成解决一切质量问题的突破口。于是团队花大量时间改措辞、加 few-shot,却不知道线上用户的实际输入分布,也不知道是哪一类输入让模型频频出错。没有观测数据,prompt 再精妙也是凭感觉。
另一种误区是:把离线评测集当成“确定性”的来源。评测集能告诉你模型平均分有没有涨,但它回答不了“为什么线上这个用户的问题会触发幻觉”“为什么昨天和今天的回答差异这么大”。评测是必要的,但它替代不了 trace。
更隐蔽的误区是:以为接入一个 LLM 监控平台就算“可观测”了。实际上,很多 LLM 监控 SaaS 只记录了模型调用本身的输入输出,却与业务链路、用户请求、Agent 工具调用完全割裂。你看到模型“某次回答很怪”,却不知道这个用户之前的 5 轮对话是什么、检索到了什么、哪一步决策导致模型采用了错误信息。
所以,AI 时代的可观测性不是“可选项”,也不是“上线后再补”,而是“成为生产级 AI 应用的基本条件”。它不性感的点在于要处理大量数据格式、字段命名、脱敏策略、链路打通,但真正让 AI 系统从“技术 Demo”走向“业务系统”的关键,恰恰是这些基础工作。
4. AI 应用可观测性:到底要观测什么
给 AI 应用做可观测性,不能只盯“模型返回了什么”。你需要从三个层次分别采集数据。
4.1 模型层
模型层关注一次 LLM 调用的直接特征:
- 模型名称与版本
- 请求参数:temperature、max_tokens、top_p 等
- 实际发送给模型的 prompt,以及经过处理后拼入的上下文
- 生成结果:response 内容、是否被截断
- 用量:prompt tokens、completion tokens、总 tokens
- 延迟:首 token 延迟、总耗时
- 成本:按 token 单价换算后的请求成本
- 是否命中缓存
这一层能回答“这个调用花了多少钱、多慢、输入和输出各是什么”。
4.2 调用链路层
模型调用不是孤立的。在 RAG 或 Agent 应用里它往往是链路中间的一环:
- 用户请求 ID / trace ID
- 上游业务上下文:用户 ID、会话 ID、来源页面
- 检索阶段:query 是什么、检索了哪个向量库、返回了哪些 chunk、相关性得分
- 上下文构造:哪些检索结果被拼入 prompt,哪部分被截断
- 工具调用:Agent 选择了哪个工具、传入了什么参数、工具返回了什么
- 代码异常:超时、限流、内容安全过滤、JSON 解析失败
这一层能回答“一次端到端请求,到底发生了几次模型调用,每一步靠什么数据决策”。
4.3 业务层
模型返回的文本本身不是业务结果,业务结果应该是用户在业务体系里定义的成功或失败。
- 用户是否满意:点赞、复制、点击、人工介入
- 业务是否达成:客服工单是否解决、推荐是否被接受、代码助手生成代码是否被采纳
- 评估分数:离线测试或在线打分模型给出的质量分数
- 人工反馈:用户举报、纠正、二次修改
业务层的作用是给模型层面的观测数据“定性”。
下面是一个简化的字段对照表,方便你规划自己的数据模型:
| 层次 | 示例字段 | 主要用途 |
|---|---|---|
| 模型层 | model、prompt、response、tokens、latency | 成本、资源、模型行为对比 |
| 链路层 | trace_id、retrieval_chunks、tool_name | 行为归因、故障定位 |
| 业务层 | user_feedback、task_success、eval_score | 业务价值判断 |
只有把这三层数据通过统一的 trace ID 关联起来,才算真正建立起了 AI 应用的可观测性。
5. 给 AI 应用加上可观测性:最小示例
下面用一个最小示例演示:如何用 OpenTelemetry 为 LLM 调用埋点,并把跟踪数据导出到控制台和 OTLP Collector。
为了避免版本问题,这里不锁定 OpenTelemetry 的具体版本,安装时请以官方最新稳定版为准。关键 API 在多个版本中基本一致。
5.1 环境准备
假设你使用 Python 3.10 以上版本。先创建虚拟环境并安装依赖:
python -m venv venv source venv/bin/activate pip install opentelemetry-api \ opentelemetry-sdk \ opentelemetry-exporter-otlp如果你只是想快速验证,不需要 Collector,可以先使用ConsoleSpanExporter,把 span 打印到终端。
5.2 用 ConsoleSpanExporter 验证手动埋点
创建一个文件ai_observability.py:
# 文件路径:ai_observability.py from opentelemetry import trace from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import ConsoleSpanExporter, SimpleSpanProcessor from opentelemetry.sdk.resources import Resource resource = Resource.create({"service.name": "ai-demo"}) provider = TracerProvider(resource=resource) # 为了方便本地验证,使用 ConsoleSpanExporter exporter = ConsoleSpanExporter() provider.add_span_processor(SimpleSpanProcessor(exporter)) trace.set_tracer_provider(provider) tracer = trace.get_tracer(__name__) def call_llm(prompt: str) -> dict: # 这里替换成你真实使用的模型客户端 # 例如 OpenAI、Anthropic、本地模型等 return { "response_text": "这是一个模拟的模型输出", "usage": { "prompt_tokens": 120, "completion_tokens": 45, "total_tokens": 165, }, "model": "your-model-name", } def run_chat(prompt: str) -> str: # 创建 span,代表一次 LLM 调用 with tracer.start_as_current_span("llm.call") as span: span.set_attribute("llm.request.model", "your-model-name") span.set_attribute("llm.request.temperature", 0.0) span.set_attribute("llm.request.prompt", prompt) result = call_llm(prompt) response_text = result["response_text"] usage = result["usage"] span.set_attribute("llm.response.text", response_text) span.set_attribute("llm.usage.prompt_tokens", usage["prompt_tokens"]) span.set_attribute("llm.usage.completion_tokens", usage["completion_tokens"]) span.set_attribute("llm.usage.total_tokens", usage["total_tokens"]) span.set_attribute("llm.cost.estimated_usd", 0.001) return response_text if __name__ == "__main__": text = run_chat("请介绍一下可观测性") print(f"模型输出:{text}")这段代码的核心逻辑:
- 初始化
TracerProvider,设置服务名为ai-demo。 - 注册
ConsoleSpanExporter,所有 span 会打印到终端。 - 用
tracer.start_as_current_span("llm.call")创建一个 span。 - 在 span 上写入模型名称、temperature、prompt、response、token 用量等属性。
- 模拟调用模型,并返回结果。
运行命令:
python ai_observability.py预期会看到类似下面的输出:
{ "name": "llm.call", "context": { "trace_id": "0x...", "span_id": "0x..." }, "attributes": { "llm.request.model": "your-model-name", "llm.request.prompt": "请介绍一下可观测性", "llm.response.text": "这是一个模拟的模型输出", "llm.usage.total_tokens": 165 } }模型输出:
模型输出:这是一个模拟的模型输出如果这一步能输出 span,说明基础埋点已经生效。
5.3 用 OTLP 导出到 OpenTelemetry Collector
控制台输出只适合本地验证,生产环境更常使用 OTLP 协议把数据发送给 Collector 或后端平台。
修改ai_observability.py,把导出器换成OTLPSpanExporter:
# 文件路径:ai_observability_otlp.py from opentelemetry import trace from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import BatchSpanProcessor from opentelemetry.sdk.resources import Resource from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter resource = Resource.create({"service.name": "ai-demo"}) provider = TracerProvider(resource=resource) otlp_exporter = OTLPSpanExporter( endpoint="http://localhost:4317", insecure=True, ) provider.add_span_processor(BatchSpanProcessor(otlp_exporter)) trace.set_tracer_provider(provider)然后准备一个最简 Collector 配置otel-collector-config.yaml:
receivers: otlp: protocols: grpc: http: processors: batch: exporters: logging: verbosity: detailed service: pipelines: traces: receivers: [otlp] processors: [batch] exporters: [logging]启动 Collector:
otelcol --config otel-collector-config.yaml再运行修改后的脚本:
python ai_observability_otlp.py此时 Collector 会收到 trace 并通过 logging exporter 输出,验证链路已经打通。
5.4 把 trace_id 写入业务日志
为了让业务日志与模型调用关联,建议在入口处获取当前 trace_id,并写入日志字段。
示例:
from opentelemetry import trace def get_current_trace_id() -> str: span = trace.get_current_span() span_context = span.get_span_context() if span_context.is_valid: return format(span_context.trace_id, "032x") return "0" * 32在业务流程中调用:
trace_id = get_current_trace_id() logger.info("chat request received", extra={"trace_id": trace_id})这样在查看业务日志时,可以通过 trace_id 跳到对应的链路追踪详情,实现日志与 trace 的双向关联。
6. 运行效果与验证
运行python ai_observability.py后,按下面顺序判断是否成功:
- 程序没有报错,且打印出“模型输出:...”。
- 控制台打印出一个包含
llm.call的 span 对象。 - span 中包含
trace_id、span_id、llm.request.prompt、llm.response.text、llm.usage.total_tokens等属性。
如果使用 OTLP 导出,还应确认:
- Collector 启动时没有端口冲突。
- 脚本执行后,Collector 日志出现对应 span 输出。
- 如果数据没有到达,先用
grpc_health_probe或curl检查端口连通性。
最容易出问题的环节通常是:
- OTel SDK 版本变化导致部分配置项名称不一致。
- 安装依赖时漏装了
opentelemetry-exporter-otlp。 - Collector 配置格式不合法,启动时报错。
- 防火墙或容器网络阻止了 4317 端口访问。
7. 常见问题与排查方法
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 控制台没有输出 span | 没有调用with tracer.start_as_current_span | 在run_chat内打印日志确认函数是否执行 | 确保进入了 span 的上下文管理块 |
| span 有输出但属性缺失 | 属性名写错或属性值类型不是 string/int | 查看代码中set_attribute调用 | 统一字段命名,参考官方语义约定 |
| OTLP 数据没有到达 Collector | endpoint 配置错误或网络不通 | 检查 Collector 日志和端口监听 | 改为insecure=True,确认 endpoint 地址 |
| trace_id 全是 0 | 当前没有活跃 span 上下文 | 在start_as_current_span内部获取 trace_id | 确认调用get_current_span的时机 |
| 成本数据统计不准 | 只记录了 model 层,没有关联业务层 | 检查 trace 关联是否打通 | 从主入口生成 trace_id,贯穿整个请求 |
| prompt/response 泄露敏感信息 | 埋点采集了原始对话内容 | 检查埋点字段是否包含 PII | 在采集端做脱敏,只保留必要字段 |
| 模型调用超时但无 trace | 客户端超时时间太短,异常被吞掉 | 在异常捕获处手动创建 span | 用record_exception记录异常并标记状态 |
这些排查思路不复杂,但都有同一个前提:数据进入统一模型之前,必须想清楚字段规范和脱敏边界。否则后面做告警、做分析都会寸步难行。
8. 工程最佳实践:在不确定性里重建确定性
8.1 把可观测性设计在接口层,而不是事后补
很多团队先把 LLM 调用写进了业务代码,等发现问题后才想加埋点,结果需要改所有调用点。
更推荐的做法是封装一个统一的模型调用入口:
class LLMClient: def __init__(self, model_name: str, temperature: float = 0.0): self.model_name = model_name self.temperature = temperature def complete(self, prompt: str) -> LLMResult: with tracer.start_as_current_span("llm.call") as span: span.set_attribute("llm.request.model", self.model_name) span.set_attribute("llm.request.temperature", self.temperature) # 调用真正的模型 SDK ... span.set_attribute("llm.response.text", result) return result只要所有调用都经过这个类,埋点就是全量的。这比到处埋print或中间件更可控。
8.2 用评估和数据双向锁定变更
模型在变、prompt 在变、上下文策略也在变。每次变更至少要回答两类问题:
- 指标上:离线评测集的分数有没有回退?
- 数据上:线上 trace 里,真实用户的输入分布、失败模式有没有变化?
只盯评测集会脱离真实场景;只盯 trace 又会缺少全局质量判断。两者必须结合。
8.3 成本归因到 trace 维度
不要只统计“今天花了多少钱”,要把成本关联到具体 trace、用户、场景和模型版本。这样当模型的 token 消耗异常上涨时,你能立刻定位到是哪些请求、哪类 prompt 造成的。
具体做法:在每次 LLM 调用的 span 中加入 estimated_cost_usd、currency、model_revision 等字段。成本数据可以和调用链一起导出,避免单独维护一份容易对不上的账单。
8.4 安全与合规边界
埋点采集的是 AI 应用的“原料”,里面可能包含用户提问、系统 prompt、检索文档、模型输出。这些字段都可能包含敏感信息。
推荐从设计阶段就把字段分成三类:
- 明文可观测:模型名、token 数、延迟、错误码。
- 可加密可观测:用户 ID、会话 ID,做好哈希或加密。
- 尽量不采集:原始 prompt 中的身份证号、电话、邮箱等 PII;如必须采集,先脱敏再入库。
尤其要注意:不要把 API Key、内部系统 prompt 的完整内容作为 attribute 打到 trace 里。对内部提示词建议只记录版本号或哈希值。
8.5 为 Agent 系统设计父子链路
Agent 应用的一个用户请求通常会触发多次模型调用和多次工具调用。如果只给每次模型调用建 span,你会看到一大堆孤立的调用,无法判断决策过程。
建议整个 Agent 执行的入口创建一个总 span,每次工具调用创建子 span,模型调用作为工具内部的子 span。通过父子结构还原“先检索、再决策、再调用工具、最后生成回复”的完整路径。
8.6 关注语义约定,但不要被字段名绑死
OpenTelemetry 社区和厂商都在推进生成式 AI 的 Semantic Conventions,包括gen_ai.*前缀等。不过这些规范仍在快速演进,不同后端对字段的支持也不一致。
更稳妥的策略是:在业务代码里定义一个稳定的内部模型,再在导出层映射到后端约定的字段。这样即使后端字段调整,你也不需要改业务代码。
9. 总结与后续学习方向
Charity Majors 谈论 AI 与可观测性时,本质上是在提醒工程师:无论模型多聪明,生产系统的可靠性仍然建立在“能够观察到正在发生什么”的基础上。AI 让系统从确定性走向概率性,但可观测性可以帮你重新获得掌控:不是让模型每一次输出都一样,而是让每一次输出都有据可查、可比较、可归因。
本文的核心判断是:Determinism 不应该是 AI 应用工程化的前提目标,Instrumentation 才是那个真正可控的支点。先把模型调用、链路层、业务层的数据采集打通,再谈 prompt 优化、Agent 框架和评测体系,你会发现很多之前“玄学”的问题,其实只是缺少一张完整的数据全景图。
下一步,建议你先做三件事:
- 阅读 OpenTelemetry 官方文档,理解 Trace、Span、Attribute、Resource 的基本概念。
- 把文中的最小示例改成你真实使用的模型 SDK,跑通一次全链路。
- 为你的核心业务场景定义 10 个左右的观测字段,并让团队讨论哪些字段必须脱敏。
做这些事不会立刻提升模型能力,但会让你的 AI 应用在进入生产环境后,不再是一个无法被调试的黑盒。基础工程永远不性感,但它决定了你能走多远。