如果你正在做 LLM 应用开发,大概率会遇到这样一个场景:本地测试全部通过,测试覆盖率数字也很漂亮——93%,哪怕 95%,你信心满满地提交代码。但上了生产环境,一个用户输入就让你精心设计的 Agent 任务链断裂,模型返回了完全无关的内容,或者一个工具调用超时,整个流程直接失败。
更麻烦的是,这个问题在本地根本复现不了。你翻开日志,只能看到大段 prompt 和模型返回,却不知道中间发生了什么。你唯一能确定的是:测试覆盖率这么高,为什么还是这么脆弱?
这篇文章要讲的,就是我在开发一个 LLM tracer(LLM 追踪器)时的真实思考:为什么它的测试覆盖率高达 93%,却依然不够用?以及,为了真正把 LLM 应用管好,我们需要在测试策略上做哪些改变。
我会从 LLM tracer 的定位出发,拆解它与传统链路追踪的区别,给出一个可运行的最小实现,然后用具体例子说明测试覆盖率的陷阱在哪,最后给出工程化的最佳实践。如果你是正在做 Agent、RAG 或复杂 prompt 编排的开发者,这篇文章会帮你少走不少弯路。
1. 这篇文章真正要解决的问题
先说结论:LLM tracer 不是用来“追踪请求”的,它是用来“捕获不确定性”的。
传统后端项目里,我们做 tracing,记录的是每个请求经过了哪些服务、耗时多少、状态码是多少。这些信息是确定性的:数据库连接失败了,日志里一定有 error;接口超时了,trace 里一定有一段慢调用。测试覆盖率高的项目,往往意味着逻辑分支都测到了,出错的地方大概率在预料之中。
但 LLM 应用不一样。
一个 LLM 应用的“请求”,通常是这样的:
result = agent.run("帮我查一下这个季度的销售数据,并生成一份分析报告")这个请求背后,可能经历了:
- prompt 模板拼接,注入用户输入;
- 模型选择、参数设置(temperature、top_p);
- 多轮工具调用、外部 API 请求;
- 上下文截断、检索结果合并;
- 模型输出的结构化解析。
其中任何一步都可能出问题,而且很多问题是非确定性的:
- 模型今天返回的 JSON 格式,明天不一样;
- temperature 调的稍微高一点,工具调用参数就从
{"action": "search"}变成了{"action": "Search"}; - prompt 里加了一句废话,某个工具就不再被使用;
- 模型版本从
gpt-4换到gpt-4o,之前所有正常流程全部乱掉。
你不可能像传统的单元测试那样,给每个分支写死输入和输出。因为输出根本不是一个固定值。
这就是测试覆盖率失灵的地方。
所以,这篇文章实际要解决三类问题:
- LLM tracer 到底该记录什么、怎么记录,才能让问题可回溯?
- 如何用测试覆盖率的视角审视 tracer 本身,但又不被覆盖率数字麻痹?
- 在 LLM 应用里,比“行覆盖率”更重要的测试维度是什么?
下面我们先从 tracer 本身说起。
2. LLM tracer 的核心概念与适用场景
2.1 传统 tracer 与 LLM tracer 的差异
传统 tracer 关注“路径”:
client -> gateway -> auth service -> order service -> db它的价值在于:当某个调用链变慢或失败,你可以快速定位是哪一环出了问题。它不关心每一环的具体内容——你不需要知道 gateway 返回的 JSON 具体是什么,只需要知道它花了 20ms 还是 200ms。
LLM tracer 关注的是“内容”和“决策过程”:
user input -> prompt build -> model call (model=gpt-4, temperature=0.7) -> tool call (action=search, query=xxx, response=yyy) -> prompt update (truncated from 5000 to 3000 tokens) -> final output (raw + parsed)它需要记录:
- 每一步的 prompt 实际长什么样;
- 模型返回的原始文本;
- 解析后的结构化结果;
- 工具调用的输入输出;
- token 消耗和延迟;
- 每一步的“决策依据”(比如为什么选择了某个工具)。
传统 tracer 可以不记录请求体,但 LLM tracer 恰恰必须记录这些内容。否则你根本无法复盘:到底是不是 prompt 里的某段话,导致模型产生了幻觉?
2.2 LLM tracer 解决什么痛点
想象你在生产环境看到了一个 bug:用户问“我的订单怎么还没到?”,Agent 的回答却是“您的订单已成功支付”。
你猜可能有两个原因:
- prompt 里订单状态字段映射错了;
- 百度回来的是支付信息而不是物流信息。
如果是传统应用,查日志立刻能定位。但 LLM 应用呢?你只有最终输入和最终输出,中间过程直接丢在模型的黑盒里。你能做的只有反复猜测,然后打日志,再等下一次出错。
LLM tracer 就是为了解决这个痛点。它把模型应用内部的“白盒”过程记录下来,让你可以像调试普通代码一样,去复盘 AI 的每一步思考。
2.3 适合用 LLM tracer 的场景
| 场景 | 为什么需要 |
|---|---|
| Agent 多工具调用 | 工具选择、参数格式不稳定,需要记录每一次调用上下文 |
| RAG 检索增强 | 检索结果直接影响生成质量,需要知道到底检索到了什么 |
| 复杂 prompt 编排 | prompt 越长越容易出 bug,必须记录实际渲染后的模板 |
| 模型版本升级 | 换个模型可能全盘崩溃,需要对比不同模型的决策轨迹 |
| 自动化评估 | 覆盖率的补足手段,需要对“行为”做回归测试 |
如果你只是写一个简单的“翻译 API”,只有一个 prompt 一个输出,那 tracer 的价值不大。但只要你开始做 Agent、多步推理、动态上下文,tracer 就是刚需。
3. 环境准备与前置条件
正经写代码之前,先把环境说清楚。下面这个示例我会用 Python 3.9+,配合openaiSDK(以通用 API 设计为例,你换成任意 LLM 服务商都行)。为了不依赖具体模型,我会定义一个LLMClient抽象,方便你对接真实模型。
需要安装的依赖:
pip install openai python-dotenv pydanticopenai:调用 LLM 的 SDK,如果你用其他厂商,替换成对应的 SDK 即可。python-dotenv:管理环境变量,比如模型密钥。pydantic:做结构化数据校验,同时用于 tracer 的数据模型。
如果只是为了跑通流程,你也可以不在乎这些依赖,直接用一个 mock 的LLMClient。但作为工程化实践,我建议还是用真实的 SDK 和完整的数据结构,这样后续写测试才有意义。
环境变量文件.env:
OPENAI_API_KEY=your_api_key_here ANTHROPIC_API_KEY=your_anthropic_key_here生产环境建议通过配置中心或 secret manager 注入,这里不展开。
4. LLM tracer 核心流程拆解
一个能用的 LLM tracer,内部最核心的流程其实是“装饰器模式” + “链路 ID 传递”。简单拆成四个步骤:
4.1 初始化一个上下文对象
每个 trace 都应该对应一个独立的“链路上下文”,里面包含:
trace_id:当前请求唯一 ID;spans:调用链中的所有步骤;metadata:模型、参数、时间戳等公共信息。
4.2 在关键位置埋点
需要在 LLM 调用、工具调用、外部 API 请求、prompt 渲染等位置埋点。最优雅的做法是写一个装饰器,比如@trace("tool_call"),然后在装饰器内部记录入参、出参、耗时和异常。
4.3 记录“决策前状态”
LLM 应用里最容易被忽略的是“决策前状态”。比如 Agent 决定调用search工具,是因为options列表里包括search,还是因为模型自己认为应该搜索?如果能够把决策前的输入、决策后的输出都记录,就能判断是 prompt 引导错了,还是模型自由发挥错了。
4.4 序列化输出
最后把 trace 对象序列化为 JSON,输出到日志或专门的 tracing 系统(如 LangSmith、Langfuse 的格式)。序列化时要小心:prompt 和输出可能很长,要设置截断策略,同时避免记录敏感信息。
下面这张表展示了每一步的输入和输出:
| 步骤 | 输入 | 输出 | 关键记录 |
|---|---|---|---|
| 构建 prompt | 用户输入 + 系统指令 | 最终的 prompt 字符串 | prompt 全文或 hash |
| 调用模型 | prompt + 参数 | 原始输出 | raw_content, usage, latency |
| 解析输出 | 原始输出 | 结构化结果 | parse_success, error_msg |
| 工具调用 | 结构化结果 | 工具返回 | 工具名、参数、返回摘要 |
5. LLM tracer 完整示例代码实现
下面我们来写一个最小但可扩展的 LLM tracer。这个示例不依赖任何具体框架,只使用 Python 标准库和 pydantic。
5.1 定义 Trace 数据结构
# tracer/models.py from datetime import datetime from typing import Any, Dict, List, Optional from pydantic import BaseModel, Field class Span(BaseModel): name: str start_time: datetime end_time: Optional[datetime] = None input_data: Optional[Dict[str, Any]] = None output_data: Optional[Dict[str, Any]] = None error: Optional[str] = None metadata: Dict[str, Any] = Field(default_factory=dict) class LLMTrace(BaseModel): trace_id: str spans: List[Span] = Field(default_factory=list) created_at: datetime = Field(default_factory=datetime.now) session_id: Optional[str] = None user_id: Optional[str] = None def add_span(self, span: Span) -> None: self.spans.append(span) def to_json(self) -> str: return self.model_dump_json()5.2 实现 tracer 装饰器
# tracer/core.py import traceback import uuid from functools import wraps from typing import Any, Callable, Dict, Optional from .models import Span, LLMTrace class Tracer: def __init__(self): self._context: Optional[LLMTrace] = None def start(self, session_id: Optional[str] = None) -> str: trace_id = str(uuid.uuid4()) self._context = LLMTrace( trace_id=trace_id, session_id=session_id, ) return trace_id def end(self) -> str: if self._context: result = self._context.to_json() self._context = None return result raise RuntimeError("No active trace") def span(self, name: str, metadata: Optional[Dict[str, Any]] = None): def decorator(func: Callable[..., Any]) -> Callable[..., Any]: @wraps(func) def wrapper(*args: Any, **kwargs: Any) -> Any: if not self._context: return func(*args, **kwargs) span = Span(name=name, start_time=datetime.now()) span.metadata = metadata or {} # 这里简化了参数序列化,实际可能需要截断敏感信息 span.input_data = {"args": str(args[:2]), "kwargs": {k: str(v)[:200] for k, v in kwargs.items()}} try: result = func(*args, **kwargs) span.end_time = datetime.now() span.output_data = {"result": str(result)[:500]} self._context.add_span(span) return result except Exception as e: span.end_time = datetime.now() span.error = f"{e}\n{traceback.format_exc()}" self._context.add_span(span) raise return wrapper return decorator tracer = Tracer()5.3 模拟一个 LLM Client 和 Agent
为了让示例不依赖具体厂商,我们写一个模拟的 LLMClient,通过配置来区分“真实调用”和“mock 调用”。生产环境只需要把内部实现换成openai.ChatCompletion.create即可。
# app.py import time from tracer.core import tracer from tracer.models import LLMTrace class MockLLMClient: """生产环境替换为 openai.ChatCompletion.create 即可""" def __init__(self, responses: dict): self.responses = responses def complete(self, prompt: str, temperature: float = 0.7) -> str: # 模拟模型非确定性:相同 prompt 偶尔返回不同格式 if "价格" in prompt: return "{'tool': 'search_db', 'query': '价格表'}" if "搜索" in prompt: return "{'tool': 'web_search', 'query': 'Python tutorial'}" # 这里故意制造一个随机失败 if time.time() % 3 == 0: raise ValueError("Model response timeout") return "{'tool': 'final', 'answer': 'I don't know.'}" def parse(self, raw: str) -> dict: return eval(raw) # 仅演示,真实项目用 json.loads 和 schema 校验 @tracer.span("build_prompt", metadata={"template": "simple_v1"}) def build_prompt(user_input: str) -> str: return f""" You are a helpful assistant. User says: {user_input} Reply strictly in JSON format. """.strip() @tracer.span("call_model", metadata={"model": "mock-gpt", "temperature": 0.7}) def call_model(client: MockLLMClient, prompt: str) -> str: return client.complete(prompt) @tracer.span("parse_model_output") def parse_model_output(raw: str) -> dict: # 真实场景应该用 json.loads + schema 校验 return eval(raw) def run_agent(user_input: str) -> str: tracer.start(session_id="session-001") try: prompt = build_prompt(user_input) raw_output = call_model(MockLLMClient({}), prompt) parsed = parse_model_output(raw_output) if parsed.get("tool") == "final": result = parsed["answer"] else: result = f"Calling tool {parsed['tool']} with query={parsed['query']}" return result finally: trace_json = tracer.end() print("=== TRACE ===") print(trace_json)5.4 运行和验证
python app.py输出大致是:
=== TRACE === {"trace_id": "xxx", "spans": [{"name": "build_prompt", ...}, {"name": "call_model", ...}]}这里的核心验证点在于:
- 如果
call_model抛出了异常,trace 里能捕获到error字段; - 如果解析失败,你能在
parse_model_output的 span 中看到原始的输出内容; - 你可以准确看到每一步的耗时。
6. 运行结果与效果验证
上面只是一个最简演示,真正要验证的不是“能跑”,而是“出了问题能回溯”。我们来做一个倒推场景:
用户输入的是“请问你们有什么产品”,模型却返回了一个搜索工具调用。你看到 trace 输出的build_promptspan 里记录的实际 prompt 是:
You are a helpful assistant. User says: 请问你们有什么产品 Reply strictly in JSON format.这样你立刻能发现,prompt 太简单了,没有明确告诉模型“必须用 final”工具回答。问题出在 prompt 设计上,而不是模型随机。
这就是 LLM tracer 最直接的价值:把不可解释的模型行为,变成可解释的开发工具体验。
如果运行过程中没有任何 trace 输出,先检查这几个地方:
- 是否在调用
run_agent之前调用了tracer.start()?没有 start,span 装饰器会直接放行,不记录任何信息。 - 是否引入了循环依赖?我建议把
tracer实例单独放在tracer/core.py,所有模块都从那里导入。 - 打印的 JSON 是否过长?建议给
input_data和output_data增加截断逻辑,只保留前 200 个字符,避免日志爆炸。
7. 测试覆盖率 93% 为什么还不够?
现在回到标题的核心问题:为什么一个测试覆盖率 93% 的 LLM tracer,依然可能在关键时刻失灵?
我们需要先重新理解“覆盖率”在 LLM 应用中的含义。
7.1 传统覆盖率衡量的是“代码分支”,不是“行为分支”
假设我们写了这样一个函数:
def parse_model_output(raw: str) -> dict: try: return json.loads(raw) except json.JSONDecodeError: return {"error": "invalid json"}单测可能覆盖了“合法 JSON”和“非法 JSON”两个分支,所以这一行代码的覆盖率是 100%。但 LLM 应用的问题在于:模型的输出空间是无限的。
今天模型返回的是{"tool": "search", "query": "x"},明天可能返回{"tool": "search", "query": "x", "extra": "y"},后天可能返回Tool: search\nQuery: x。你的json.loads只能处理第一种。覆盖率不会告诉你这一点,因为测试用例里根本没有第二种输入。
7.2 非确定性导致覆盖率的“时间偏移”
写单测的时候,你可能把 prompt 作为固定输入,mock 一个固定输出,所以测试稳定通过。但生产环境的输入时刻在变:
- 用户输入不同,prompt 渲染结果不同;
- 上下文窗口截断策略会改变 prompt 的实际内容;
- 模型服务商发布新版本,行为漂移;
- temperature 不是 0,同一个 prompt 可能产生多个不同结果。
测试覆盖率是静态的,而 LLM 应用是动态的,两者之间天然存在错位。
7.3 测试覆盖率高但没覆盖到“跨步骤时序”
LLM Agent 应用最大的复杂度不在单一函数内,而在步骤之间的顺序和依赖。你的代码可能 93% 的行都被测了,但 Agent 的“思考链”时序却从来不在单测范围里。
举个例子:你的 Agent 先调用搜索工具,再调用知识库工具,最后汇总答案。如果步骤顺序错了——先查知识库再搜索——最终答案的质量会完全不同,但代码行覆盖率不会体现出来。传统断言的测试很难写出这种“两步协作是否正确”的测试。
7.4 覆盖率的“数字虚荣心”
当团队以覆盖率为 KPI 时,开发者会倾向于写那些容易覆盖的测试,比如 getter/setter、纯函数、mock 返回值,而避开真正难的测试——比如“模型在超长 context 下还能保持格式”“工具调用参数顺序是否稳定”。
所以,我不反对覆盖率,但我要强调:在 LLM 应用里,覆盖率只是一个基础门槛,不是质量保证。它应该低于 100%,但需要配合其他维度的测试策略。
8. 比覆盖率更重要的测试策略与最佳实践
如果你接受了上面的判断,接下来就该思考:除了提高代码行覆盖率,LLM 应用还能怎么测试?
我给出的建议是:构建“三层测试金字塔”。
8.1 第一层:确定性单测(覆盖率在这里有用)
这一层解决的是“代码逻辑是否正确”的问题,覆盖的是纯函数、数据解析、工具调用封装等。比如:
def test_parse_valid_json(): raw = '{"tool": "search", "query": "pricing"}' parsed = parse_model_output(raw) assert parsed["tool"] == "search"这类测试非常适合追求高覆盖率。它让我们确信:只要模型的输出符合预期格式,我们的处理逻辑不会出 bug。
8.2 第二层:语义回归测试(针对模型输出)
这一层要解决的是“模型行为是否稳定”的问题。我们可以用一组精心挑选的“黄金样例”,断言模型输出的语义符合期望,而不是准确匹配字符串。
示例:用真实模型调用,对固定的 prompt 输入,跑 5 次,检查:
- 是否返回合法 JSON;
tool字段是否在允许的集合中;- 如果指定了
query,是否非空。
你可以把这一层和 tracer 结合,跑完自动输出 trace,方便失败的定位。
# test_semantic_regression.py import json from app import build_prompt, call_model, parse_model_output def test_model_stability(): prompts = [ "帮我查北京天气", "介绍一下你的功能", "把第三季度销售数据做成表格", ] for p in prompts: raw = call_model(MockLLMClient({}), build_prompt(p)) parsed = parse_model_output(raw) assert "tool" in parsed8.3 第三层:端到端场景回放(最接近生产)
这一层最贵,但也最有效。把生产环境中真实用户的输入(脱敏后)保存下来,组成一个“回放集”。每次发版前,用回放集跑一遍完整 Agent,然后对比前后两端的结果差异。
这个差异可以通过 tracer 自动比较:
- 是否走了不同的工具链;
- 是否出现了解析失败;
- 是否超时;
- 最终答案的向量相似度。
这里 tracer 几乎成了必备设施,因为它天然记录了每一步的状态,回放对比变得简单。
8.4 工程团队的其他最佳实践
除了测试策略,还有几个工程细节值得注意:
- 为 tracer 定义清晰的敏感数据策略:prompt 和输出可能包含用户 PII,记录前做脱敏或过滤,否则会变成新的合规风险。
- 为 trace 设置采样率:全量记录成本高,一般按错误样本 100%、成功样本 1%~5% 采样即可。
- 将 trace 和报警打通:当出现
error、parse_fail、timeout时,自动告警到 IM。 - 使用“trace 快照”做错误回归:每次 bug 出现时,把当时的 trace 保存为测试 fixture,之后作为回归测试的输入。
8.5 一个简单的 trace 对比函数
打个比方,你可以在 CI 脚本里加一段这样的逻辑:
# regression/compare_traces.py from tracer.models import LLMTrace import json def load_trace(path: str) -> LLMTrace: with open(path) as f: data = json.load(f) return LLMTrace.model_validate(data) def compare_traces(before: LLMTrace, after: LLMTrace) -> bool: if len(before.spans) != len(after.spans): return False for b, a in zip(before.spans, after.spans): if b.name != a.name: return False if (b.error is None) != (a.error is None): return False if b.output_data != a.output_data: # 这里可以做模糊比较,比如 JSON 结构是否一致 return False return True这类函数配合 pytest,可以在模型升级时自动发现“哪些场景的行为发生了变化”。
9. 常见问题与排查思路
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| trace 里没有数据 | 没有调用tracer.start()或跨线程传递 | 检查请求入口是否先调用了 start | 使用 contextvars 传递 trace context |
| trace 太长,日志刷屏 | 记录了大段 prompt 和响应 | 查看 span 的 input/output | 增加截断配置,只存前 N 字符或摘要 |
| 敏感信息泄漏到 trace | 用户输入包含身份证、地址 | 查看记录的原始字段 | 增加脱敏过滤器,如mask_pii() |
| 模型输出解析失败但无异常 | 解析函数内部兜底,返回了空 dict | 检查 trace 中parse_model_output的 output | 让解析函数主动抛异常,以便追踪 |
| 不同模型行为差异无法定位 | 没有记录模型版本和参数 | 查看 span metadata | 在装饰器 metadata 中增加model,temperature |
| 覆盖率很高但线上还是崩 | 测试集没有覆盖真实模型输出分布 | 运行语义回归测试 | 增加黄金样例集和 trace 回放 |
10. 总结与后续学习方向
这篇文章从“93% 测试覆盖率却依然脆弱”这个矛盾点切入,讲清楚了 LLM tracer 的核心价值——它记录的不只是调用链路,而是“模型行为”的上下文。相比传统 trace,LLM tracer 需要在关键位置记录 prompt、原始输出、解析结果、工具调用参数,以及每一步的错误信息。
同时,我们应该清醒地认识到,测试覆盖率在 LLM 应用中只是基础指标。它用来衡量代码分支是够的,但无法衡量模型行为的动态分布。为了把 LLM 应用做扎实,需要把测试策略升级成“确定性单测 + 语义回归 + trace 回放”三层结构,而 tracer 是连接这三层的关键基础设施。
如果你现在正在开发 Agent 或 RAG 应用,建议尽快把 tracer 纳入基础能力,哪怕只是一个简单的装饰器版本。它能让你在生产事故发生时,从“玄学猜测”变成“精准定位”。后续你可以进一步研究 LangSmith、Langfuse 等成熟的 tracing 平台,也可以自己扩展 trace 对比、自动化评估、异常告警等能力。
但请记住,工具再强,也比不上你对“模型不确定性”的深刻理解。测试覆盖率不是免死金牌,tracer 也不是银弹——真正的可靠性,来自持续的测试沉淀、trace 复盘,以及对每一处不确定性的敬畏。