Prompt 可观测性:调 AI 输出不能只看最终答案
一次线上事故让我重新审视 Prompt 调试
去年秋天,我负责的一个客服问答系统突然出了一批「答非所问」的回复。用户问退货政策,模型开始聊起了产品规格。值班同学第一反应是改 Prompt,加了一句「你必须严格回答用户问题」。改完上线,好了两个小时,又复发了。
排查了整整一个下午,最终发现根因不在 Prompt——是 RAG 检索服务的 embedding 模型版本被人升级了,召回的片段跟退货场景完全不相关。模型拿到的上下文就是错的,Prompt 写得再好也没用。
这件事让我意识到,调 AI 输出不能只盯着最终答案。你需要看到生成过程中每一步发生了什么:用户输入了什么、系统拼了什么上下文、检索返回了哪些片段、模型拿到的是什么版本的 Prompt、输出有没有过 schema 校验。这些「过程数据」加在一起,才是 Prompt 调试真正的证据链。
业内把这套能力叫做 Prompt 可观测性(Prompt Observability)。它不是一个新框架或者新工具,而是一种工程思路:把 Prompt 从「一段文本」变成「一条可追踪的请求链路」,让每一次生成都有据可查。
可观测性的三根支柱和 Prompt 调试的特殊性
传统软件工程的可观测性靠 metrics、logs、traces 三根支柱。到了 LLM 应用里,这三样都不能少,但每样的内涵都要扩展。
Metrics 层面,除了常规的延迟、错误率、吞吐量,你还需要关注 token 消耗、每次请求成本、输出长度分布、工具调用成功率。这些指标能帮你发现趋势性退化,比如某个 Prompt 改完后平均 token 数翻倍了,成本曲线明显上翘。
Logs 层面,光记「模型返回了 200」远远不够。一条合格的 LLM 日志至少要包含完整的 Prompt 输入(system prompt + user message + 注入的上下文)、模型参数(model name、temperature、top_p、max_tokens)、原始输出、token 用量。如果涉及工具调用,还要记录每次 tool call 的名称、参数和返回值。
Traces 是最关键也最容易被忽略的一环。一个复杂请求可能经过检索、改写、多轮工具调用、最终生成等多个步骤。每个步骤是一次 span,串起来就是一条完整的 trace。Braintrust 在 2026 年的 agent 可观测性指南中把 span 分成了四类:tool-call span(记录工具名、参数、返回值、重试次数)、reasoning span(记录模型的规划和决策路径)、state transition span(记录工作记忆的变更)、memory operation span(记录长期存储的读写)[1]。这种分类能帮你在一条 trace 里区分「检索出了问题」和「模型推理跑偏了」,而不是混在一起猜。
Prompt 调试跟传统 debug 最大的区别在于非确定性。同一个 Prompt 跑两次,输出可能不一样。所以你没法靠「复现一次看看」来定位问题,必须依赖记录下来的过程数据做离线分析。这也是为什么可观测性在 LLM 应用里比在传统服务里更重要。
一次请求到底要记录什么
我把一条完整请求需要记录的信息拆成了七层。每层对应一类可能的失败原因:
| 层级 | 记录内容 | 对应失败类型 | 示例字段 |
|---|---|---|---|
| 输入层 | 用户原始输入 + 系统补充信息 | 用户表达含糊、输入被截断 | raw_input, input_tokens, input_source |
| 模板层 | Prompt 模板 ID、版本号、变量值 | 模板版本混乱、变量注入错误 | template_id, template_version, variables |
| 参数层 | 模型名称、temperature、top_p 等 | 参数配置漂移、模型切换未通知 | model, temperature, top_p, max_tokens |
| 上下文层 | RAG 检索片段、排序分数、来源 | 检索不相关、排序失效、embedding 漂移 | retrieved_chunks, scores, chunk_source |
| 工具层 | 工具调用名称、参数、返回值、耗时 | 工具参数幻觉、返回值异常、超时 | tool_name, tool_args, tool_result, duration_ms |
| 输出层 | 模型原始输出、schema 校验结果 | 格式不合规、内容截断、JSON 解析失败 | raw_output, schema_valid, parse_error |
| 反馈层 | 用户反馈、人工审核结论 | 质量问题未被发现、缺少回归样本 | user_rating, reviewer_comment, flag_reason |
这七层不是都要从零开始建。像 LangSmith、Langfuse、Arize Phoenix 这类平台已经能自动采集大部分数据,你要做的是确保采集覆盖全、字段语义对齐、脱敏规则到位[2][3]。
脱敏这件事值得单独说。Prompt 里经常出现用户姓名、手机号、地址、订单号,甚至对话里的敏感内容。日志系统一旦泄露,后果比普通服务日志严重得多。我见过有团队把 API key 写进了 trace metadata,也有人把用户身份证原文存进了检索上下文。建议在写入日志之前加一层 PII 过滤,至少遮盖手机号、身份证、银行卡、API key 这几类高敏字段。
三个真实调试场景
光说理论太抽象,我拿三个实际场景走一遍,展示可观测性是怎么帮定位问题的。
场景一:回答突然变啰嗦
现象:某个摘要生成接口,最近一周平均输出长度从 150 字涨到了 400 字,用户反馈「总结不够精炼」。
如果只看最终输出,你只会看到「模型变啰嗦了」。但有了 trace,你可以逐层排查:
- 检查输入层——用户输入长度没变,排除输入膨胀。
- 检查模板层——发现三天前有人把模板从 v2.1 切到了 v3.0,新版本去掉了「请用三句话概括」的约束。
- 检查参数层——temperature 一直是 0.3,没有漂移。
定位到模板版本变更,回滚到 v2.1,输出长度恢复正常。
如果没有记录模板版本号,这个排查可能要花一整天去翻 git history 问「谁改了 Prompt」。
场景二:工具调用死循环
现象:一个 agent 帮用户查天气,正常情况下一次 tool call 就能返回结果。某天开始,部分请求出现了 tool call 死循环——模型反复调用 get_weather 但每次传入不同的城市名。
通过 trace 查看 reasoning span,发现模型的推理路径是这样的:
- 用户问「明天北京天气」→ 模型规划:先查北京天气 → tool call 成功 → 拿到结果
- 但模型判断「结果格式不对,没有温度信息」→ 重新规划:换一个城市试试 → 调用
get_weather("上海") - 再次拿到结果,还是觉得「格式不对」→ 继续循环
根因是工具返回值的格式变了——之前是 {"city": "北京", "temp": 25},上游有人改成了 {"location": "北京", "temperature": 25, "unit": "celsius"}。模型在新格式下没能正确提取信息,于是陷入了重试循环。
如果没有 tool-call span 记录每次调用的参数和返回值,这种问题完全无法复现。
场景三:RAG 检索静默退化
这就是我开头提到的退货政策案例。用户问退货相关问题,模型回答了产品规格。trace 显示:
- 输入层:用户输入「你们的退货政策是什么」,语义清晰。
- 模板层:Prompt 要求「根据以下信息回答用户问题」,约束明确。
- 上下文层:检索返回了 5 个片段,全部来自产品规格文档,退货政策文档的召回分数排在了第 20 名之后。
问题出在 embedding 模型升级后,「退货政策」和「产品规格」在向量空间里的距离变近了。新模型对电商领域术语的编码方式跟旧模型不一样,导致检索排序失效。
这个案例说明,有些失败是「静默」的——系统没有报错,每个环节都返回了 200,但最终结果就是错的。只有把上下文层的检索片段和排序分数记录下来,你才能发现这种静默退化。
用 trace 给失败分类
有了完整的 trace 数据,可以把 Prompt 质量问题归到六个类别。每个类别对应不同的修复手段:
| 失败类别 | 典型表现 | 排查线索 | 修复手段 |
|---|---|---|---|
| 输入不足 | 模型回答偏离、反问用户 | 用户输入过短、缺少关键上下文 | 增加输入引导、追问机制 |
| 检索失败 | 回答与问题不相关 | 检索片段相关性分数低、来源错误 | 换 embedding 模型、调召回策略 |
| Prompt 约束不清 | 输出格式不稳定 | Prompt 缺少格式要求、示例不足 | 加约束、补 few-shot 示例 |
| 模型未遵守格式 | JSON 解析失败 | 输出有 markdown 包裹、多余文本 | 加后处理、用 structured output |
| 后处理错误 | 内容被截断或乱码 | 输出在中间断开、编码异常 | 调 max_tokens、检查编码管道 |
| 工具返回异常 | 工具调用失败或死循环 | 工具返回值格式变更、超时 | 修工具接口 schema、加超时重试 |
这个分类最大的价值在于:不是所有问题都靠改 Prompt 解决。检索失败你改 Prompt 没有用,工具返回值变了你得改工具接口,模型参数漂移你得去查配置管理。分错类就会走错方向。
从 trace 到调试工具链
2026 年做 LLM 可观测性的工具已经形成了比较成熟的生态。我按部署方式和适用场景做了一个对比:
| 工具 | 开源/商业 | 核心能力 | 部署方式 | 适合场景 |
|---|---|---|---|---|
| LangSmith | 商业 | tracing + prompt 管理 + playground + 评测 | SaaS | LangChain 生态、团队协作 |
| Langfuse | 开源 (MIT) | 轻量 tracing + prompt 版本 + 指标监控 | 自托管 / Cloud | 快速集成、成本敏感 |
| Arize Phoenix | 开源 | tracing + eval + 向量可视化 | 本地 / 自托管 | 本地开发、RAG 调试 |
| MLflow Tracing | 开源 | tracing + prompt 版本 + trace replay + LLM-as-Judge | 自托管 | 已有 MLflow 生态的团队 |
| Datadog LLM Observability | 商业 | prompt tracking + agent observability + 告警 | SaaS | 已有 Datadog 基础设施 |
| Pydantic Logfire | 商业 | 深度 Python 集成 + tracing + 评测 | SaaS | Pydantic 重度用户 |
选择的核心考量不是功能多少,而是你的技术栈和团队习惯。如果已经用 LangChain,LangSmith 的集成成本最低。如果重视数据隐私、需要自托管,Langfuse 和 Phoenix 是更实际的选择。如果你整个可观测性都在 Datadog 上,那直接用它的 LLM 模块比另起一套系统要顺畅得多[4][5][6]。
OpenTelemetry 在这个生态里扮演的角色越来越重要。Agenta 在 2025 年发的一篇指南里详细讲了怎么把 OTel 用到 LLM 可观测性上——用标准化的 span 格式,让 tracing 数据可以在不同工具之间流转,避免被单一厂商锁定[7]。如果你从零开始建,建议直接基于 OTel 标准来做,后期换工具的迁移成本会低很多。
代码层面:从裸日志到结构化 trace
讲完工具选型,再看一下代码层面怎么做。我从最原始的 print 调试到结构化 trace,分四个阶段做对比。
阶段一:裸日志(最常見的反面教材)
import openai
def answer_question(query: str) -> str:
response = openai.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": query}],
temperature=0.3,
)
result = response.choices[0].message.content
print(f"Query: {query}") # 只记了输入和输出
print(f"Result: {result}")
return result这段代码出了问题你什么也查不到——不知道用了哪个 Prompt 版本,不知道检索了什么上下文,不知道 token 消耗,更不可能回溯某次具体请求的完整链路。
阶段二:结构化日志(有进步,但缺少关联)
import logging
import json
import openai
logger = logging.getLogger(__name__)
def answer_question(query: str, context: list[str]) -> str:
prompt = f"根据以下信息回答:\n{''.join(context)}\n\n用户问题:{query}"
response = openai.chat.completions.create(
model="gpt-4o",
messages=[
{"role": "system", "content": "你是客服助手,请简洁回答。"},
{"role": "user", "content": prompt},
],
temperature=0.3,
max_tokens=500,
)
result = response.choices[0].message.content
logger.info(json.dumps({
"query": query,
"context_chunks": context,
"model": "gpt-4o",
"temperature": 0.3,
"output_tokens": response.usage.completion_tokens,
"result_preview": result[:200],
}))
return result好了一些,至少记录了上下文和模型参数。但这些日志是扁平的,缺少 trace ID 和 span 关联。当一次请求涉及多次模型调用和工具调用时,你很难把这些日志串成一条完整的链路。
阶段三:接入 Langfuse(有 trace、有 span、有版本管理)
from langfuse import observe
from langfuse.decorator import observe as langfuse_observe
import openai
@observe()
def retrieve_context(query: str) -> list[str]:
# 检索逻辑,自动记录为 retriever span
chunks = search_knowledge_base(query, top_k=5)
return chunks
@observe()
def generate_answer(query: str, context: list[str]) -> str:
prompt_template_v = "v3.2" # 关联 Prompt 版本
prompt = build_prompt(query, context, version=prompt_template_v)
response = openai.chat.completions.create(
model="gpt-4o",
messages=prompt,
temperature=0.3,
max_tokens=500,
)
return response.choices[0].message.content
@observe()
def answer_question(query: str) -> str:
context = retrieve_context(query)
answer = generate_answer(query, context)
# 一次请求自动串成一条 trace
# 包含 retriever span + llm span + 各自的输入输出和耗时
return answer用了 @observe() 装饰器之后,每一次函数调用自动变成 trace 里的一个 span。Langfuse 会记录输入、输出、耗时、token 用量,并在 UI 上展示成一条可视化的链路。你还能在 Langfuse 控制台里关联 Prompt 版本、对 trace 打标签、做评测。
阶段四:基于 OpenTelemetry 的标准化 trace(可迁移、可扩展)
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
import openai
# 配置 OTel tracer
provider = TracerProvider()
exporter = OTLPSpanExporter(endpoint="http://localhost:4317")
provider.add_span_processor(BatchSpanProcessor(exporter))
trace.set_tracer_provider(provider)
tracer = trace.get_tracer("prompt-observability")
def answer_question(query: str) -> str:
with tracer.start_as_current_span("retrieve") as retrieve_span:
retrieve_span.set_attribute("query", query)
chunks = search_knowledge_base(query, top_k=5)
retrieve_span.set_attribute("chunk_count", len(chunks))
retrieve_span.set_attribute("top_score", chunks[0].score if chunks else 0)
with tracer.start_as_current_span("generate") as generate_span:
prompt = build_prompt(query, chunks)
generate_span.set_attribute("prompt_template_version", "v3.2")
generate_span.set_attribute("model", "gpt-4o")
generate_span.set_attribute("temperature", 0.3)
response = openai.chat.completions.create(
model="gpt-4o",
messages=prompt,
temperature=0.3,
)
result = response.choices[0].message.content
generate_span.set_attribute("output_tokens", response.usage.completion_tokens)
return result这种写法的好处是 trace 数据走 OTel 标准协议,可以导出到 Langfuse、Phoenix、Jaeger、Datadog 或者任何支持 OTLP 的后端。换工具的时候不需要改业务代码,只需要换 exporter。
四个阶段的对比:
| 阶段 | 数据完整度 | 可查询性 | 链路关联 | 可迁移性 | 接入成本 |
|---|---|---|---|---|---|
| 裸日志 | 极低 | 靠 grep | 无 | 无 | 几乎为零 |
| 结构化日志 | 中等 | JSON 查询 | 手动关联 | 依赖日志平台 | 低 |
| Langfuse 等专用工具 | 高 | 内置 UI + API | 自动 | 绑定平台 | 中 |
| OpenTelemetry 标准化 | 高 | 取决于后端 | 自动 | 高(OTLP 通用) | 中高 |
调试闭环:从发现问题到防止复发
可观测性帮你定位了问题,但定位完之后还需要一个闭环机制,确保同一个问题不会再次出现。这个闭环分四步走,Braintrust 在他们的指南里给了一个渐进式的时间线[1]:
第一步(上线第一天):让每条请求可见。接入 tracing,记录 LLM 调用和工具调用,捕获所有错误。这时候你还没有评测,但至少出了问题能看到 trace。
第二步(第一周):把早期遇到的失败案例转化成评测数据。给 trace 加上 session 关联,开始计算每条 trace 的成本。建立基本的告警——错误率飙升、延迟突增时能收到通知。
第三步(第一个月):对线上 trace 做采样评分。可以用规则(比如输出是否包含必要字段),也可以用 LLM-as-Judge(让另一个模型判断输出质量)。发现质量退化时及时告警,并关联到具体 trace。
第四步(第一个季度):把线上失败案例持续沉淀到评测数据集。在 CI 里跑评测,每次 PR 都对比质量分数,低于阈值就阻止合并。这时候你的评测集是从真实用户行为中生长出来的,而不是凭空编造的。
这个闭环的关键在于第三步和第四步——在线评测(online evaluation)和离线评测(offline evaluation)要配合使用。在线评测盯住实时流量,离线评测守住发布关卡。两者共享同一套评测定义,确保本地跑出来的分数跟线上一致。
落地前的自检清单
如果你准备在自己的项目里落地 Prompt 可观测性,用这个清单过一遍:
- 每次 LLM 请求都有唯一的 trace ID,能在日志系统里检索到
- Prompt 模板有版本号,每次请求记录了使用哪个版本
- 模型参数(model、temperature、top_p、max_tokens)作为结构化字段记录,不是散落在文本日志里
- RAG 检索返回的片段内容、来源和排序分数都写入了 trace
- 工具调用记录了工具名、参数、返回值、耗时和错误状态
- 输出经过了 schema 校验,校验结果(通过/失败、错误详情)记录在 trace 里
- 日志里有 PII 过滤层,手机号、身份证、API key 等敏感字段被遮盖
- 关键指标有告警规则:错误率、延迟 P99、平均 token 消耗、工具调用失败率
- 线上失败案例有渠道沉淀为评测数据,不只是修了就忘
- CI 里有评测流水线,PR 合并前会跑回归测试
- trace 数据保留策略明确:热数据保留多久、冷数据归档到哪里、谁能访问
- 团队成员知道在哪里看 trace、怎么按 trace ID 查一次请求的完整链路
这 12 条不需要一天全部做到。按前面的渐进路线,先把前三条做了就能解决大部分「出了问题查不到原因」的困境。
写在最后
Prompt 可观测性的本质是把「改 Prompt 碰运气」变成「看证据做决策」。你不再需要靠经验猜测「是不是检索的问题」或者「是不是 temperature 太高了」,而是直接打开 trace,看到每一步的输入、输出和中间状态,用数据定位问题。
这件事的投入不小——接入 tracing、建设评测、维护数据集,都需要持续投入。但相比每次线上出问题都花几个小时盲猜,这笔投入的回报是确定的。
我的建议是从最小可行方案开始:先给你的 LLM 调用加一个 @observe() 装饰器,让 trace 跑起来。有了数据之后,其他的都会自然展开。
参考资料
- [1] Braintrust. Agent Observability: The Complete Guide for 2026. 2026-05.
- [2] Langfuse. LLM Observability & Application Tracing (Open Source). 2026.
- [3] Arize AI. LLM Tracing and Observability with Arize Phoenix. 2023-10.
- [4] MLflow. Top LLM Observability Tools in 2026: A Pro Guide. 2026-05.
- [5] Laminar. Top 6 Agent Observability Platforms (2026): A Developer's Ranking. 2026-04.
- [6] LangChain. LangSmith: AI Agent & LLM Observability Platform. 2026.
- [7] Agenta. The AI Engineer's Guide to LLM Observability with OpenTelemetry. 2025-08.
- [8] Statsig. Prompt Observability: Debugging AI Interactions. 2025-10.
- [9] Weights & Biases. A Guide to LLM Debugging, Tracing, and Monitoring. 2025-11.