把接口调用链 Trace 搬到 AI 应用:给 retrieval / prompt / llm / tool / postpr

简介: 本文提出将OpenTelemetry全链路Trace引入AI应用,为retrieval、prompt、LLM、tool、postprocess五阶段埋点,记录可断言的中间产物(如召回ID、Prompt长度、工具入参等),生成结构化jsonl作为可回放、可验证的测试证据,实现精准归因、自动化断言与回归测试。

把接口调用链 Trace 搬到 AI 应用:给 retrieval / prompt / llm / tool / postprocess 每段都留可断言的中间产物把接口调用链 Trace 搬到 AI 应用:给 retrieval / prompt / llm / tool / postprocess 每段都留可断言的中间产物

一个 AI 应用给出了错答案,你打开日志想查为什么,却只看到孤零零一行「模型返回:xxx」。检索到底召回了哪些段落?Prompt 最后拼成了什么样、有没有超长?中间调了哪几个工具、入参出参是什么?每步花了多少时间、烧了多少 token——全都没有记录。

结果就是三连问都答不上来:错了没法归因,改完没法验证,回归更无从谈起。这篇以质量审计报告体讲一件具体的事:用 OpenTelemetry 把一条 AI 请求的全链路 Trace 补全,让「模型为什么这么答」变成一份可回放、可断言的测试证据。

一、审计结论摘要

审计对象是这套 AI 应用的可观测性与可测试性,三条发现按风险从高到低。

第一,日志只记最终输出,链路中间态全丢。出错时无法回答「错在检索、Prompt、模型还是工具」,归因全靠猜,属阻断级。

第二,中间产物没有落成可断言的结构化数据。召回文档 id、Prompt 长度、工具入参这些关键量从来没被记录,也就没法写「召回不得为空」「Prompt 不得超长」这类断言。

第三,改完无法验证、回归无从谈起。因为没有任何一条用例能重放一次请求、比对每一段的中间产物,改动是否引入退化只能靠人肉抽查。

建议动作三条:用 OpenTelemetry 给 retrieval / prompt_build / llm_call / tool_call / postprocess 五段各埋一个 span 并记录关键属性、把 span 导出成结构化 jsonl、用 pytest 对这份 Trace 写断言让它可回归。第四节给可运行实现,第五节给 Trace 分段清单。

二、先厘清目的:可观测性是为了做测试证据,不是为了大屏

先给结论:AI 应用的可观测性,价值不在监控大屏好不好看,在于它能不能把「一次回答是怎么来的」变成一份可回放、可断言的测试证据。这两件事看着像,其实差得远。

大屏关心的是聚合指标——平均延迟、错误率、QPS,它回答「系统整体健不健康」。测试证据关心的是单条请求的每一段中间产物——这一次召回了哪几篇、Prompt 拼成了什么、工具返回了什么,它回答「这一次为什么答成这样」。前者是运维视角,后者是质量视角,本篇只谈后者。

把它锚回你熟悉的东西:这就是接口测试里的调用链 Trace。过去 Trace 记录的是一次请求穿过哪些微服务、每一跳的耗时和返回;现在同一条链路上多了 AI 特有的几段——检索、Prompt 组装、模型调用、工具调用、后处理。原理完全一样:每一段都留下可断言的中间产物,出错时顺着链路一段段往回看,就能定位到底哪一段坏了。区别只是,被测对象从确定性的服务返回,变成了非确定性的模型输出,所以更要靠中间态来归因。

三、一条 AI 请求该拆成哪几段

结论:把一次 AI 请求拆成五个可埋点的阶段,每段记它能回答「为什么这么答」的关键属性。

retrieval(检索):记录召回的文档 id 列表、召回条数、相似度分数——它回答「模型看到的信息对不对」。prompt_build(Prompt 组装):记录最终 Prompt 的字符/token 长度、模板版本、是否超长被截断——它回答「喂给模型的输入长什么样」。llm_call(模型调用):记录模型名与版本、输入输出 token 数、耗时——它回答「模型这一跳发生了什么」。tool_call(工具调用):记录工具名、入参、出参、是否报错——它回答「模型有没有拿到正确的外部结果」。postprocess(后处理):记录是否命中过滤、是否改写、最终输出长度——它回答「模型原始输出到用户之间被动了什么」。

这五段串起来,就是一条完整的、可回放的证据链。下面是可运行实现。

四、可运行实现:OpenTelemetry 埋 span + 导出 jsonl

实现分两段。第一段用 OpenTelemetry 给五段各埋一个 span、记录关键属性,并用自定义 exporter 把 span 落成 jsonl;第二段用 pytest 对这份 Trace 写断言。

"""
trace_ai.py —— 用 OpenTelemetry 给一条 AI 请求埋全链路 span,导出成 jsonl
运行:python trace_ai.py   (生成 trace.jsonl)
真实项目里把 fake_* 换成你的检索/模型/工具调用即可。
"""
import json
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SpanExporter, SimpleSpanProcessor


class JsonlExporter(SpanExporter):
    """把每个 span 连同它的关键属性落成一行 json,作为可回放的测试证据。"""
    def __init__(self, path="trace.jsonl"):
        self.path = path

    def export(self, spans):
        with open(self.path, "a", encoding="utf-8") as f:
            for s in spans:
                f.write(json.dumps({
   
                    "name": s.name,
                    "attrs": dict(s.attributes),
                    "duration_ms": round((s.end_time - s.start_time) / 1e6, 2),
                }, ensure_ascii=False) + "\n")

    def shutdown(self):
        pass


provider = TracerProvider()
provider.add_span_processor(SimpleSpanProcessor(JsonlExporter()))
trace.set_tracer_provider(provider)
tracer = trace.get_tracer("ai.request")


def handle_query(question: str):
    """一条 AI 请求:五个阶段各埋一个 span,每段记能归因的关键属性。"""
    with tracer.start_as_current_span("ai_request") as root:
        root.set_attribute("question.len", len(question))

        with tracer.start_as_current_span("retrieval") as sp:
            docs = fake_retrieve(question)           # 你的检索
            sp.set_attribute("retrieval.doc_ids", [d["id"] for d in docs])
            sp.set_attribute("retrieval.count", len(docs))

        with tracer.start_as_current_span("prompt_build") as sp:
            prompt = fake_build_prompt(question, docs)
            sp.set_attribute("prompt.char_len", len(prompt))
            sp.set_attribute("prompt.template_version", "v3")
            sp.set_attribute("prompt.truncated", len(prompt) > 8000)

        with tracer.start_as_current_span("llm_call") as sp:
            raw = fake_llm(prompt)
            sp.set_attribute("llm.model", "demo-model")
            sp.set_attribute("llm.model_version", "2026-08")
            sp.set_attribute("llm.out_tokens", len(raw) // 4)

        with tracer.start_as_current_span("tool_call") as sp:
            result = fake_tool(raw)
            sp.set_attribute("tool.name", "calc_refund")
            sp.set_attribute("tool.ok", result.get("ok", False))

        with tracer.start_as_current_span("postprocess") as sp:
            final = fake_postprocess(result)
            sp.set_attribute("post.filtered", False)
            sp.set_attribute("post.final_len", len(final))
        return final


# —— 下面是桩,真实项目替换为实际实现 ——
def fake_retrieve(q): return [{
   "id": "doc_1"}, {
   "id": "doc_2"}]
def fake_build_prompt(q, docs): return f"根据{[d['id'] for d in docs]}回答:{q}"
def fake_llm(prompt): return "退款金额为 99 元"
def fake_tool(raw): return {
   "ok": True, "value": 99}
def fake_postprocess(result): return f"退款金额:{result['value']} 元"


if __name__ == "__main__":
    handle_query("我的订单能退多少钱")
    provider.force_flush()
    print("已导出 trace.jsonl")

为什么这么写:把五个阶段做成嵌套 span、都挂在 ai_request 这个根 span 下,是因为回放时要能一眼看出「这些段属于同一次请求」,父子关系就是这条证据链的骨架。每个 span 只记「能用来归因和断言」的属性——retrieval 记 doc_ids 和 count、prompt 记长度和是否截断、llm 记模型版本和 token、tool 记入参出参和是否成功——是因为 Trace 不是记得越全越好,记多了噪声大、也难断言,关键是把「为什么这么答」的几个决定量钉住。自定义 JsonlExporter 把 span 落成结构化 jsonl 而不是发到监控后端,是因为本篇要的是「可被 pytest 读取、可断言、可版本化比对」的测试证据,不是一张实时大屏。踩过的坑有两个:一是 SimpleSpanProcessor 是同步导出,测试里用它才能保证 handle_query 返回时 jsonl 已落盘;生产环境要换 BatchSpanProcessor,但那样测试里就得显式 force_flush,否则读到空文件、断言假绿。二是 prompt.truncated 这类布尔属性一定要在埋点时就记下来,事后想从 Prompt 长度反推「当时到底截没截断」是推不出来的——归因证据必须在现场留。

09-配图1.png

五、把 Trace 变成会失败的断言

结论:Trace 光记下来还不够,要能用 pytest 对它写断言,它才从「日志」升级成「测试证据」。

"""
test_trace.py —— 对导出的 Trace 写断言,让「为什么这么答」可回归
运行:先 python trace_ai.py 生成 trace.jsonl,再 pytest -q test_trace.py
"""
import json
import pytest


def load_trace(path="trace.jsonl"):
    with open(path, encoding="utf-8") as f:
        return {
   json.loads(l)["name"]: json.loads(l) for l in f if l.strip()}


@pytest.fixture(scope="module")
def spans():
    return load_trace()


def test_retrieval_not_empty(spans):
    """召回不得为空——空召回意味着模型是在凭空编。"""
    assert spans["retrieval"]["attrs"]["retrieval.count"] > 0


def test_prompt_not_truncated(spans):
    """Prompt 不得被截断——截断会悄悄丢掉关键上下文。"""
    assert spans["prompt_build"]["attrs"]["prompt.truncated"] is False


def test_tool_call_succeeded(spans):
    """工具调用必须成功——工具失败却继续答,就是幻觉高发点。"""
    assert spans["tool_call"]["attrs"]["tool.ok"] is True


def test_model_version_pinned(spans):
    """模型版本必须锁定在期望值——版本漂了,答案漂了要能归因到这一跳。"""
    assert spans["llm_call"]["attrs"]["llm.model_version"] == "2026-08"

为什么这么写:每条断言都对应一个「会让答案变错、但从最终输出看不出来」的中间态——召回为空、Prompt 被截断、工具失败却硬答、模型版本悄悄换了。这正是全链路 Trace 的价值:最终答案对的时候这些断言全绿,一旦某段坏了,你不用去猜,断言直接告诉你是哪一段。把它们写成 pytest 用例,就能进回归——每次改检索、改 Prompt 模板、换模型,都重放一批请求、比对每段中间产物,退化当场暴露。踩过的坑:断言要钉在「结构化的中间属性」上,别去断言最终那段自然语言文本等不等于某句话;模型输出是非确定性的,对文本做等值断言只会假红一片,而 retrieval.countprompt.truncatedtool.ok 这些中间量是稳定的,才是可回归的抓手。

六、审计清单:一条 Trace 作为测试证据,该查什么

审计项 报告里必须出现什么 缺失时的后果
分段完整性 retrieval/prompt/llm/tool/post 五段都有 span 出错时无法定位坏在哪一段,归因靠猜
关键属性 每段记了能归因的量(doc_ids/长度/版本/入参出参) 有链路无细节,看得见跳数看不见内容
可回放 span 导出成结构化 jsonl,可被用例读取 Trace 只进了大屏,没法做回归比对
可断言 对中间态写了会失败的断言,不只记不判 记了等于没记,退化不会自己变红
父子关系 各段挂在同一 root span 下,能认出属同一次请求 多条请求的 span 混在一起,无法回放单条

再补一张两种做法的对照,说明为什么「只记最终输出」不够:

维度 只记最终输出的日志 全链路 Trace 作为测试证据
可归因 只知道答错了,不知错在哪段 顺链路定位到 retrieval/prompt/tool 具体一段
可复现 中间态没留,复现全靠猜输入 每段属性落盘,可重放同一批请求
可回归 无法比对,改完只能人肉抽查 pytest 断言中间态,改动退化当场变红
调试成本 高——反复加日志重跑碰运气 低——一次埋点,长期复用为证据

这五项没有一项需要额外预算,只是把「记一行模型返回」升级成「给链路每段埋点、导出、断言」。反过来说,一份只记最终输出、连召回条数和 Prompt 长度都没留的 AI 应用日志,它出问题时给不出任何归因依据,也就撑不起一次像样的质量审计。

AI 应用的可观测性,真正的验收标准不是大屏有多漂亮,而是「随便挑一条错误回答,你能不能顺着 Trace 一段段指出它到底坏在哪」。

记不住「怎么答出来的」,就没资格说「测过了」——Trace 不是日志的装饰,是回答的可回放证据。

你们的 AI 应用出错时,日志能回答「检索召回了什么、Prompt 拼成了什么样」吗?评论区聊聊。

相关文章
|
9天前
|
人工智能 自然语言处理 安全
阿里云千问办公 QwenWork详细介绍:产品核心能力、典型场景、价格及常见问题解答
千问办公是阿里云推出的一站式AI办公平台,主打"不止于对话,更注重交付",依托通义千问旗舰大模型,用户一句话即可完成数据分析、PPT生成、视频剪辑等复杂任务,直接输出可用成果。产品深度打通钉钉生态与企业OA,覆盖桌面端、网页端,提供企业标准版198元/人/月等多档订阅方案,新用户注册即赠2000积分,适配工程师、HR、财务等多职业办公场景,成为能动手干活的"全能AI同事"。
|
9天前
|
人工智能
千问办公官网入口:阿里AI办公QwenWork产品页和免费网页端链接
千问办公官网含两大入口:一是网页端(qwenwork.cn),即开即用,支持浏览器直接访问;二是阿里云产品页 https://t.aliyun.com/U/JNKJuO 提供免费/付费版详情、功能介绍及使用指南。
|
15天前
|
网络协议 Linux iOS开发
【2026实测】Wireshark下载+安装+汉化+使用教程(图文版,巨详细)
Wireshark 是一款免费开源的网络协议分析工具,可实时捕获、解析并可视化数据包,助你诊断网络故障、分析通信协议(如HTTP、DNS、TCP等)。支持Windows/macOS/Linux,含中文界面,新手入门便捷。(239字)
|
10天前
|
人工智能 API 内存技术
刚刚 DeepSeek V4.1 Flash 开启内测,1 分钟教你用上!
刚刚 DeepSeek 内测群发布了 DeepSeek V4.1 Flash 中间版本内测的消息,这次的模型采用了新的结构,原生支持多模态、能力更强、速度更快、且成本更低。
1907 15
|
8天前
|
IDE 开发工具
Qoder 上线 Sonus 模型,Computer Use 能力全面增强
Qoder国际版上线全新内置大模型Sonus(/ˈsoʊnəs/),全球领先,专精超长任务执行与电脑操作(Computer Use)。配合Qoder桌面端0.2.3版本,可自主完成编程、金融建模、科研及表格制作等复杂工作。现全面支持Qoder全系产品,效率提升3.2倍。
1017 1
Qoder 上线 Sonus 模型,Computer Use 能力全面增强
|
14天前
|
人工智能 运维 BI
阿里云千问办公QwenWork深度解析:基于Qwen3.8,六大核心能力重构企业全自动化工作流与计费选型指南
传统AI办公工具大多停留在对话问答、文档摘要、简单文案生成层面,只能完成单点碎片化任务,无法自主拆解复杂业务流程,很难串联多工具、多文档、外部业务系统完成端到端完整工作交付。很多企业在落地AI办公的时候,需要组合多款不同工具,来回切换界面,手动复制粘贴中间结果,智能化改造落地门槛居高不下。千问办公QwenWork是整合多款智能体产品能力打造的一体化企业办公智能体平台,底层基座依托Qwen3.8大模型,打通桌面端Agent、云端Agent、企业协同Agent三种运行形态,不再局限简单问答,接收业务目标之后自主拆解任务步骤,调用各类工具,处理文档、表格、浏览器自动化、数据查询,直接输出可交付的办公
1669 4
|
10天前
|
缓存 人工智能 自然语言处理
阿里云qwen3.8-flash大模型介绍:模型能力、模型价格、免费额度与最新活动
本文是阿里云百炼平台Qwen3.8-Flash大模型的选型接入指南,作为兼顾性能与响应速度的高性价比多模态模型,它支持百万级上下文窗口、全场景多模态输入与完整智能体能力矩阵,适配编程辅助、智能体协作等核心场景。文中同步梳理了最新下调的阶梯定价、夜间4折等优惠活动,搭配OpenAI兼容流式调用示例,帮助开发者低成本快速落地高并发AI应用。
阿里云qwen3.8-flash大模型介绍:模型能力、模型价格、免费额度与最新活动
|
16天前
|
缓存 数据可视化 开发工具
DeepSeek Harness 怎么更新?dsh 更新完整指南:更新本体(npx、npm、源码)与更新插件两种方式
DeepSeek Harness 的更新分两层:本体更新(npx 自动最新、npm update -g、源码 git pull)与插件更新(插件市场点更新、命令行覆盖安装)。本文按「准备 → 更新本体 → 更新插件 → 更新后检查」四步走,覆盖新手常见疑问。
1819 1
DeepSeek Harness 怎么更新?dsh 更新完整指南:更新本体(npx、npm、源码)与更新插件两种方式
|
11天前
|
SQL 人工智能 前端开发
QoderWake 1.0 正式发布:从桌面里的 Agent,到工作现场的数字员工
QoderWake v1.0正式发布:企业级数字员工团队平台。支持“一句话建岗”,预置10类特训岗位;Waker常驻钉钉/飞书群,@即响应、自动协作、跨任务记忆;具备定时/事件/API多触发方式与统一任务看板;已沉淀27.6万条记忆、12.3万项技能,助力组织实现人机协同增效。
821 2
|
9天前
|
缓存 测试技术 API
DeepSeek V4.1 Flash 内测接入:改个模型名即可调用(附代码)
DeepSeek V4.1 Flash 内测不用申请,base_url 不变、改个模型名就能调,9/10 到期。本文讲清接入、计费限流与多模态注意点。
831 0
DeepSeek V4.1 Flash 内测接入:改个模型名即可调用(附代码)

热门文章

最新文章