☰
可观测性驱动的大模型应用Prompt A/B评测闭环实践
2026/10/17 11:52:38 网站建设 项目流程

最近一个多月,我一直在帮团队把一套 RAG 客服系统从“能跑”调到“能改”。那种感觉就像在给一个不断漂移的靶子画靶心:Prompt 改一句,线上指标可能跳 5 个点,但没人能说清楚是哪部分出了问题。后来我把 Langfuse、LangSmith 和 OpenTelemetry(OTel)串成了一条完整的链路,才真正把“效果靠感觉”变成了“评测有数据、回归有基线、上线有把握”。这篇文章就是把这一版实践沉淀下来,版本号我记成了 24.8,主要围绕一个核心问题:如何用可观测能力驱动 Prompt A/B 评测闭环。

这套东西不是某一个工具的功劳,而是三个工具的接力:Langfuse 负责线上的链路追踪和打分明细,LangSmith 负责离线数据集和 Prompt 回归实验,OTel 则是中间的“通用语”,让不同系统的 trace 结构能对得上。文章会把我踩过的坑、验证过的配置、以及一个可以直接抄的 A/B 评测流程都写出来。适合正在做大模型应用工程化、被 Prompt 调优搞到头大的同学,也适合刚接触可观测体系的团队做选型参考。

1. 为什么大模型应用需要一个评测闭环:从“能跑”到“能改”

1.1 大模型应用与传统软件的最大差别:行为不可预期

传统软件只要输入不变,输出就是确定的。大模型应用不是这样,同一个 Prompt、同一份参数,丢给同一个模型,返回结果都可能不一样,更别提模型版本升级、上下文窗口占满、RAG 召回质量波动这些变量。做了一段时间之后我发现,团队里最大的内耗不是模型能力不行,而是“出了问题不知道怪谁”。

有一次我们线上准确率突然掉了 4 个点,排查了半天,最后发现是某个 Prompt 模板里多了一个换行符,触发了解析路径变化。这种问题如果只靠人工抽查聊天记录,基本不可能发现。可观测性要解决的就是这件事:把每一次模型调用的输入、输出、Token 消耗、耗时、链路上下文全部记录下来,让“效果变化”变成可追溯的“数据变更”。

这背后其实是一个观念转变:大模型应用的核心代码不再是 if-else,而是 Prompt 和数据。Prompt 就是我们的“代码”,评测就是我们的“单测”。没有评测闭环的应用,就像没有测试用例的代码库,改起来全靠胆量。

1.2 评测闭环的核心环节:采集、标注、评估、回归、上线

评测闭环不是一个工具,而是一个持续运转的流程。我用五个环节来拆解:采集、标注、评估、回归、上线。

  • 采集:把线上真实请求完整记录下来,这是最宝贵的数据来源。包括用户问题、检索结果、模型输出、评分、用户行为反馈。
  • 标注:针对采集到的样本,打上 ground truth。无论是人工标注还是模型自评,都要先解决“什么是对的答案”这个问题。
  • 评估:用离线数据集跑不同 Prompt 版本,得出可量化指标,比如准确率、完整率、幻觉率、拒绝率。
  • 回归:每次改 Prompt、换模型、调参数之后,在固定数据集上重跑一遍,确保没有反向退化。
  • 上线:通过回归的新版本以小流量放出,再做在线 A/B 对比,同时继续采集新数据,循环迭代。

这个闭环里最关键的一点是:数据集必须是稳定的。没有稳定数据集,所有评测结果都是噪声。LangSmith 的 Dataset 功能其实就是帮我们管理这个“测试集”,而 Langfuse 则管着“线上运行日志”,两者一旦打通,闭环就成立了。

1.3 可观测与评测的先后顺序:先有 trace,再有 eval

我见过很多团队,一上来就急着搭评测平台,结果发现没有数据可测,因为线上调用根本没有埋点。这是常见的误判。评测的前提是可观测,可观测的前提是每一条请求都能被追踪。

所以我的建议是先花一周时间把链路追踪建起来,把线上的真实请求全部录进 Langfuse,等积累到几百条真实样本之后,再拿这些样本做标注、构造数据集。先有 trace,再有 eval,顺序不能反。这也是我这次把 OTel 放在架构最底层的原因:没有一套统一的数据标准,后面接什么评测工具都会别扭。

2. 可观测体系选型:Langfuse、LangSmith、OTel 的分工与连接

2.1 三个工具各管什么

很多初学者会把 Langfuse 和 LangSmith 搞混,觉得都是“给 LLM 用的监控工具”。它们确实有重叠,但定位差别很大。我用一句话概括:Langfuse 管线上运行时的 trace 和评分,LangSmith 管离线数据集的实验与回归。

具体来说:

  • Langfuse:一个开源的可观测平台,核心概念是 Trace、Span、Generation、Score。它的重点是把线上每一次 LLM 调用的完整链路、输入输出、成本、延迟记录下来,并且支持在任意环节打“分数”,比如人工评分、模型评分都可以回传到对应 trace。
  • LangSmith:偏重实验管理和 Prompt 回归。你可以把一组评测样本做成 Dataset,然后对不同的 Prompt 模板或模型配置跑一轮 Experiment,自动出来对比结果。
  • OpenTelemetry(OTel):一个厂商中立的标准。它定义了 trace 的格式、span 的语义,以及怎么上报数据。简单说,如果 Langfuse 和 LangSmith 是两座城市,OTel 就是连接它们的高速公路。

这三者的关系不是互相替代,而是互补。我用 Langfuse 看线上真实情况,用 LangSmith 做离线回归,中间靠 OTel 把数据模型统一起来,避免每一套系统各搭各的管道。

2.2 OTel 在闭环里的位置:不只是协议,是数据模型

OTel 早期做的是链路追踪标准,比如我们熟悉的 trace_id、span_id、parent_span_id。但在大模型场景下,光有链路还不够,还得有语义。比如“这次调用用了哪个模型”“输入了多少 token”“输出是什么”,这些都需要一个统一字段规范。

OTel 社区早就给生成式 AI 场景设计了语义约定,常见的包括gen_ai.request.model、gen_ai.response.model、gen_ai.usage.input_tokens、gen_ai.usage.output_tokens。这些字段的存在意味着:只要你的埋点遵循这套约定,无论你用的是哪个模型厂商 SDK、哪个追踪平台,数据都能被识别和解析。

我理解 OTel 最大价值不是“上报数据”,而是“统一描述数据”。它让 Langfuse 能接收 OTel 格式的 trace,也让 LangSmith 的实验结果可以被纳入同一个语义框架。尤其当一个团队同时使用多个模型厂商时,没有统一模型描述,后面的成本和效果对比就会乱成粥。

2.3 Langfuse 与 LangSmith 的边界:在线 trace 与离线 experiment

Langfuse 和 LangSmith 看起来都是“追踪 + 评测”,但使用场景有一个明显区分:线上和线下。

线上场景重在时效和细节。一条线上请求进来,我们关心的是它调了哪个 Prompt、用了哪些上下文、返回了什么、花了多少钱、用户最后有没有点“有用”。这些都是 Langfuse 的强项,它能把一次请求的整个链路串起来,还支持在 trace 上挂打分。

离线场景重在批量对比。我们要评估“Prompt V1 vs Prompt V2”哪个更好,需要固定样本集、批量跑完、逐条对比输出。LangSmith 的 Dataset 和 Experiment 就是为了这个场景设计的。同一个数据集可以反复跑,每次跑完自动生成对比报告。

这块我补充一个细节:LangSmith 和 Langfuse 都能打分,但它们的打分用途不一样。Langfuse 的 Score 更多反映线上反馈,比如用户点赞、客服标记、自动检查规则;LangSmith 的跑分则是实验结论,比如这条样本答对了没有、相似度多少。我在实践中会两边都保留,线上评分进 Langfuse,离线评测结论进 LangSmith,互相印证。

3. 落地步骤一:打通 OTel 到 Langfuse 的观测链路

3.1 Langfuse 接入的三种方式

Langfuse 的接入并不只有官方 SDK 这一条路,我根据团队实际情况整理了三种方式。

第一种是官方 Python/JS SDK。上手最快,直接在代码里初始化 Langfuse 客户端,用@observe()装饰器或手动封装来记录调用。适合从零开始、代码可控性高的项目。

第二种是 OTel 原生端点。Langfuse 本身暴露了一个兼容 OTel 的上报端口,你只需要把 OTel 的 Trace Exporter 指向这个端口,它就能接收标准的 OTLP 数据,并把 trace 自动映射到 Langfuse 的模型里。这个方式适合已经有 OTel 埋点的服务,代码改动最小。

第三种是通过 OTel Collector 中转。在服务与 Langfuse 之间架一个 Collector,服务端把 OTLP 数据发给 Collector,Collector 经过批量、过滤、字段加工之后再转发给 Langfuse。这种方式最灵活,适合多服务、多后端、需要数据脱敏的场景。

我当时选择的是第三种,因为团队里已经有服务用标准 OTLP 上报,我不想再为 Langfuse 单独写一套埋点。拦截层统一做字段修正,后续换平台也方便。

3.2 用 OTel GenAI 语义约定规范埋点

直接上报裸 trace 给 Langfuse 会有一个问题:平台无法识别“这是 LLM 调用”。所以必须在 span 上打上 GenAI 语义字段。下面这段是我在代码里实际使用的 OTel 埋点风格,用的是 Python 的opentelemetry-sdk:

from opentelemetry import trace from opentelemetry.sdk.resources import Resource from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import BatchSpanProcessor from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter resource = Resource.create({ "service.name": "rag-customer-service", "deployment.environment": "prod", }) provider = TracerProvider(resource=resource) provider.add_span_processor(BatchSpanProcessor( OTLPSpanExporter(endpoint="http://otel-collector:4318/v1/traces") )) trace.set_tracer_provider(provider) tracer = trace.get_tracer("rag.tracer") def trace_llm_call(query, prompt, response, model, input_tokens, output_tokens, duration_ms): span = tracer.start_span("chat/completions", kind=trace.SpanKind.CLIENT) span.set_attribute("gen_ai.request.model", model) span.set_attribute("gen_ai.request.prompt", prompt) span.set_attribute("gen_ai.response.text", response) span.set_attribute("gen_ai.usage.input_tokens", input_tokens) span.set_attribute("gen_ai.usage.output_tokens", output_tokens) span.set_attribute("llm.latency_ms", duration_ms) span.end()

这段代码的关键点在于字段名。Langfuse 收到 OTel trace 后,会按 GenAI 语义约定识别模型、token、响应文本字段。你如果自创字段名,虽然数据也能上去,但平台没法把 token 数、模型名这些关键维度解析出来,后续看板和分析就全废了。

3.3 采集哪些核心指标:token、成本、延迟、质量分数

管线通了之后,要确定“采集什么”。我提炼了四类必须采集的数据。

  • 基础标识:trace_id、session_id、user_id、request_id。这是关联一切数据的主键。
  • 性能指标:模型名、模型版本、首字延迟、总耗时、输入 token、输出 token、总成本。
  • 内容数据:系统 Prompt、用户消息、检索到的上下文、模型完整回复。这部分涉及数据脱敏和存储成本,需要做裁剪,但绝不能完全丢弃,否则无法排查质量问题。
  • 反馈数据:用户是否点赞/点踩、客服标记是否正确、业务侧完成的率。

我们当时用 Langfuse 自身的 Score API 把反馈数据挂到 trace 上。下面是打分示例:

from langfuse import Langfuse langfuse_client = Langfuse() langfuse_client.score( trace_id="trace_id_xxx", name="user_feedback", value=1, comment="回答准确", )

Score 的作用是给 trace 打标签。线上每一条请求最终会被贴上多个标签,比如“用户满意”“检索漏召回”“拒绝错误”。有了这些标签,后面做 A/B 时就不只盯 token 成本,还能看质量维度。

3.4 通过 OTel Collector 做数据脱敏

大模型场景最大的数据合规隐患,就是用户问题可能带着手机号、账号、地址等信息直接进了日志系统。我强烈建议在 Collector 层做一次字段过滤或者哈希处理,不要等到存进库里之后再清理。

可以在 Collector 的 transform processor 里写规则,对gen_ai.request.prompt和gen_ai.response.text做正则脱敏。比如把 11 位手机号替换成掩码。这样 Langfuse 里存的数据已经是脱敏后的数据,既能排查问题,也降低后续数据合规审查的风险。

这一段是纯经验之谈,当时我们为了省事跳过脱敏直接全量记录,结果安全团队查日志时发现真实手机号,整改了一周。越早加脱敏,代价越小。

4. 落地步骤二:用 LangSmith 做 Prompt A/B 的离线评测

4.1 构造评测数据集:不要拿生产日志直接当 ground truth

很多团队拿到生产日志,直接就当成评测数据集开始跑分。这个做法有隐患:生产日志只是“模型的输出”,不是“标准答案”。模型输出丢了个关键信息,日志不会告诉你这是对的还是错的。

正确做法是先做样本标注。从 Langfuse 导出一批线上请求,人工或半自动标注出标准答案,再入进 LangSmith 的 Dataset。标注时我会尽量覆盖以下几类场景:

  • 典型问题:比如“怎么退款”“怎么查物流”,每个类目 10 到 20 条。
  • 边界问题:相似意图但实际业务不同,比如“补差价”和“退差价”,需要模型能区分。
  • 对抗样本:故意加口语化表达、错别字、长文本,模拟线上真实噪声。
  • 拒绝样本:哪些问题模型应该明确拒绝,而不是强行编造。

数据集不需要特别大,我的经验是每个评测子类 50 到 100 条样本已经能跑出一个相对可信的基线。数据集稳定之后,尽量不要往里面随意加样本,每次加样本都当成一次版本更新。

4.2 跑一次 A/B Experiment:发布 Prompt 之前必须看的回归

LangSmith 的 Experiment 逻辑很好理解:给定一个 Dataset,给定一个 Prompt 模板或 Agent 配置,跑一遍,得到所有样本的输出,再跟 ground truth 对比。

我一般会维护两套 Prompt:一套是线上基础版本 baseline,另一套是待上线的候选版本 candidate。跑 Experiment 时两套都跑,然后对比结果。示例代码大致像这样:

from langsmith import Client client = Client() dataset = client.create_dataset( dataset_name="customer_service_24_8", description="客服 RAG 评测基准集" ) client.create_examples( dataset_id=dataset.id, inputs=[{"question": "订单怎么取消?"}], outputs=[{"answer": "在订单详情页点击取消..."}], ) # 使用 Runner 跑一次实验(简化示意) runner = client.create_prompt_experiment( dataset_id=dataset.id, experiment_prefix="prompt_v3_candidate", prompt_identifier="prompt_template_v3", )

跑完之后,LangSmith 会输出一份对比表,包含每条样本的输出、正确与否、token 消耗等。我一般重点看三类指标:整体正确率、错误样本分布、成本变化。一个候选 Prompt 的正确率提升哪怕只有 2 个点,只要错误样本分布没有明显恶化,我就会考虑放到线上小流量测试。

这里有个经验:Experiment 不是跑一次就完。只要改了代码、换了检索或改了模型,都必须把同一个数据集重新跑一遍。否则你根本不知道线上指标波动是改 Prompt 造成的,还是改检索造成的。

4.3 评测指标与 LLM-as-judge 的做法

相比于传统分类任务,大模型输出的评测更让人头大。同样一个问题,标准答案 30 个字,模型输出 80 个字,意思对了但表达完全不同,简单字符串比对根本不可靠。

我的做法是分三层评测。第一层是规则检测,适合可以明确校验的场景,比如 JSON 格式是否正确、是否包含指定关键词、是否包含链接。第二层是相似度打分,用 ROUGE、BLEU 或者向量余弦相似度,适合对表达顺序有要求的任务。第三层是 LLM-as-judge,用一个更强大的模型来当裁判,对输出进行多维度评分,比如正确性、完整性、简洁性、是否幻觉。

用 LLM-as-judge 时,我给裁判模型写的评测 Prompt 有固定模板,里面必须包含四个要素:原始问题、标准答案、待测模型输出、评分维度说明。这样跑出来的分数才可重复。另外,裁判模型本身也有一定随机性,我建议每条样本至少评两次取均值,用温度设为 0,进一步降低波动。

5. Prompt A/B 评测的真实运营流程:数据回流与决策机制

5.1 A/B 分组与流量划分策略

离线评测通过之后,下一步就是线上小流量验证。线上 A/B 的关键在于分组逻辑。最简单的做法是基于用户 ID 或会话 ID 做哈希分流,这样同一个用户始终落在同一组,避免用户体验跳变。

这里有个权衡:如果你只关心“最终答案质量”,按请求粒度做随机分组就能保证统计样本的独立性;如果你关心“用户长期体验”,就要按用户粒度分组。我当时做的是按会话 ID 哈希分流,因为客服场景中同一个用户的多轮交互属于一个会话,按会话分组既容易实现,也不会让用户一会儿看到新版答案、一会儿看到旧版答案。

分组代码很简单,哈希取模即可:

import hashlib def get_ab_group(session_id: str) -> str: ab_hash = int(hashlib.md5(session_id.encode()).hexdigest(), 16) return "candidate" if ab_hash % 100 < 20 else "baseline"

这里 20 表示候选版本只拿 20% 流量,稳妥起见不要 50/50 直接开。先小流量跑一天,看看有没有明显异常,再逐步放量。

5.2 在线评测与离线评测如何闭环互证

很多人以为 A/B 评测就是“线上看准确率哪个高”,实际上在线场景很难直接拿到 ground truth。你只知道用户有没有点有用、有没有发起转人工,但这些是间接信号。

所以我的闭环思路是双向印证。离线评测的时候,提前在数据集上定义好“什么算优秀”,跑出一个预期值。线上 A/B 跑完,我再看两组之间的用户反馈率、转人工率、平均修复轮次等指标。如果离线评测说 V3 更好,线上数据却不支持,问题大概率出在数据集与线上分布不一致,这时就去检查线上日志,把新出现的典型问题补进数据集。

这个“线上发现问题 -> 补数据集 -> 离线回归 -> 再上线上”的循环,就是评测闭环真正的运转方式。它不会让效果一次到位,但会让每一次改动越来越有把握。

5.3 样本量与显著性判断

A/B 评测最容易犯的错是样本量不够就下结论。我见过有人只跑了几十条数据就说新版准确率提升 10%,这种结论在统计上没有任何意义。

关于样本量,用个简单直觉判断:如果我们关注的是二分类指标,比如“回答是否让用户满意”,两组之间如果差异只有 1 到 2 个点,那至少需要几千到上万条样本才能达到统计显著;如果差异有 5 个点以上,可能几百条就能看出趋势。我一般会在实验配置里提前算好最小样本量,别等数据出来了再问“够不够”。

也可以用现成的显著性校验工具,比如对两组比例做 Z 检验或卡方检验,p 值低于 0.05 就认为有显著差异。没有显著差异时,我的原则是“不要上线”。既然数据不能证明新版本更好,就没有理由承担上线风险。

5.4 上线前的 checklist

跑完数据还不够,上线之前我习惯过一遍 checklist,避免低级失误。

  • 确认候选版本已经在 LangSmith 数据集上完成回归,正确率不低于基线。
  • 确认线上 trace 可以正常区分 baseline 和 candidate 两个版本。
  • 确认 Langfuse 已经按 prompt_version 维度打好标签,方便后续数据回查。
  • 确认成本增幅在可接受范围,特别是 candidate 版本如果增加了 prompt 长度,token 成本可能翻倍。
  • 确认脱敏规则对 candidate 版本同样生效,新的 Prompt 模板里如果有新的可变字段,也要做合规检查。

这些事项看起来琐碎,但每一条都对应着我踩过的坑。尤其“成本增幅”这条,我们曾经有一个 Prompt 版本在 A/B 中效果略好,但输入 token 增了 60%,算下来月度成本多了一大截,最终还是被拦下了。

6. 常见问题与排坑实录

6.1 Langfuse 里看不到完整 trace

原因大概率埋点创建了独立 span,但没有串到同一个 trace。检查时看两点:一是所有 span 是否共享同一个 trace_id,二是 Langfuse 是否把请求正确映射到了 trace。常见问题出在@observe()和手动创建 span 混用,导致父子关系错乱。我的经验是统一走一套入口封装,不要这边装饰器、那边手动埋点。

6.2 OTel 字段上报了,但 token 数据解析不出来

多半是字段名不对。OTel GenAI 语义约定里,token 相关字段应该挂在 span attributes 上,字段名建议写成gen_ai.usage.input_tokens和gen_ai.usage.output_tokens。如果上报成别的自定义名字,Langfuse 无法识别,虽然能查到原始 attributes,但无法参与平台自身的指标计算和成本估算。

6.3 LangSmith 数据集越跑越脏

这是离线评测的经典问题。团队里每个人都往里加样本,加着加着数据集就看不懂了。我的对策是给数据集立规矩:新增样本必须附带类别、来源、标注置信度。并且每次更新数据集都当成一次发布,记录变更说明。没有记录的样本,宁可不用。

6.4 评测分数很高,线上效果却很差

恭喜你,遇到了数据集与线上分布不一致的问题。常见原因有两个:数据集过窄,只覆盖了少量高频场景;或者标注答案本身带入了主观偏好,比如标注者喜欢较长回答,模型就往长了写。这时候要回到 Langfuse 里拉线上真实请求日志,重新做一轮数据分布分析,而不是继续在旧数据集上调 Prompt。

6.5 LLM-as-judge 打分手感不稳定

裁判模型也会飘。我遇到过同一批样本,上午跑和下午跑结果差异明显,最后发现是裁判模型的温度参数没有固定,以及不同批次间混入了少量长尾样本影响判断。把温度固定为 0、每条样本重复评分取均值之后,稳定性好多了。更严格的做法是对裁判模型也做一次一致性评估,确认它对同一输入重复输出的方差可接受。

6.6 可观测数据成本失控

记录详细的输入输出必然带来存储成本上涨,尤其大模型响应文本是几倍于用户问题的体量。我们会做三层裁剪:第一层,全量采样保留一周;第二层,固定比例采样保留一个月;第三层,系统关键错误全量永久保留。样本量如果还是太大,就对响应文本做摘要再存储,但注意摘要字段和原始字段要分清,别混在一起。

实操中的两点心得

第一点,工具链搭建不是越复杂越好。我见过团队把 Langfuse、LangSmith、OTel、Prometheus、Grafana 全接上,结果没人看得完数据,最后全部停用。我这次只保留了真正产生决策依据的三件套,简单到团队成员看一眼就能定位问题。评测闭环的价值不在于数据量大,而在于每次改动都能拿出证据说话。

第二点,Prompt 版本管理一定要前置。我当时是先让 Langfuse 里每条 trace 都带上了prompt_version标签,后来做 LangSmith 回归实验时才能轻松把历史版本捞出来对比。如果一开始没有版本概念,后面所有分析都只能靠“我记得当时用的模板好像是这个”来还原,效率极低。

24.8 这版实践走到今天,最直观的改变是:团队里再也没有人敢基于感觉改 Prompt 了。每次改完,LangSmith 数据集跑一遍,Langfuse 线上数据验证一遍,两个闭环对上,才允许动线上流量。这套流程虽然朴素,但正是大模型应用从“玄学调参”走向“工程化迭代”最务实的一步。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询