Prompt 可观测性:调 AI 输出不能只看最终答案

0阅读11分钟

一次线上事故让我重新审视 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,你可以逐层排查:

  1. 检查输入层——用户输入长度没变,排除输入膨胀。
  2. 检查模板层——发现三天前有人把模板从 v2.1 切到了 v3.0,新版本去掉了「请用三句话概括」的约束。
  3. 检查参数层——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 + 评测SaaSLangChain 生态、团队协作
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 + 评测SaaSPydantic 重度用户

选择的核心考量不是功能多少,而是你的技术栈和团队习惯。如果已经用 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 跑起来。有了数据之后,其他的都会自然展开。


参考资料

评论 0

0 / 1000