面向人工智能智能体的最小可行实验平台:A/B测试与影子运行
如何利用管道定义、本地分配、暴露事件、追踪数据、沙箱评估、影子运行以及约束机制来决定是否用版本B的代理替换版本A。
判断新版本智能体是否优于当前生产环境中的版本并非看上去那么简单。在开发阶段,这一流程还算可控:准备一组测试用例,运行两个版本,查看部分运行日志,让另一个模型对输出结果进行评分,从而决定该改动是否值得进一步投入精力。但当目标不再是简单的提示词或单次模型调用,而是一个由各组件按自身时间表运行且交互方式并不总是显而易见的系统时,事情就会变得复杂起来。
想象一下由这些组件构成的客户支持智能体:
agent runtime
memory and retrieval
system prompt
model
tools
guardrails
application logic
假设检索策略发生了变化,测试集上的任务完成度提升了。这可能意味着该智能体确实变得更优秀了,但也可能是它调用了更多模型次数,导致成本上升20%,在与真实用户的交互行为上有所差异,或是受益于同一时间段内恰好发生变化的其它组件。在这种情况下,这项工作就不再像是模型评估,而更像是一次实验。
以下是为解答一个相对简单的问题而设计的、最基础且可靠的平台架构概要:
should version B replace version A?
只有达到一定规模时,这种设计才有意义。单个智能体、二十个人工审核的测试用例以及少数内部用户并不需要其大部分功能。只有当多个团队独立修改模型、提示词、内存、工具及智能体实现方案,实验持续不断进行,且最终必须有人依据数月后仍清晰可读的证据来批准生产环境中的变更时,该平台才能发挥作用。全公司通用的智能体平台正符合这一描述。
B的真正含义
第一个问题很基础。假设某个实验这样声明:
A = agent-v17
B = agent-v18
agent-v18究竟是什么?如果在实验过程中提示词发生变化,检索索引会被重新构建,模型网关也会更换其默认模型,而标签几乎无法说明实际评估了什么内容。这对智能体尤其不利,因为最终观察到的行为源自整个处理流程,而非某一次单独的模型调用。
因此,不可变的处理流程定义正是此次评估的对象。它可能看起来像这样:
pipeline:
name: support-agent
version: 18
components:
runtime:
artifact: agent-runtime@sha256:...
memory:
artifact: memory-service@sha256:...
configuration:
strategy: hybrid
top_k: 8
model:
route: support
model: provider/model-x
prompt:
artifact: sha256:...
tools:
- artifact: customer-lookup@sha256:...
- artifact: refund-tool@sha256:...
具体的工件格式其实并不重要。关键在于能够将某种变体重新构建成系统配置,这样数月之后仍能知道分配给B的用户原本应运行什么程序。这还能让实验平台不受其他变动因素的影响。某个实验可能会进行如下对比:
model X vs model Y
还有人可能会这样比较:
memory top_k=5 vs top_k=8
第三种方式可能会让两种完全不同的代理实现相互竞争。从实验角度来看,这些其实都只是流程的变体而已。一旦实验开始,这些定义就会固定下来。如果需要更改处理方式,则会创建新的实验迭代,因此B的历史含义绝不会被悄无声息地改写。
分配机制不应成为生产系统的依赖项
接下来要解决的问题是誰获得A,誰获得B。一种诱人的设计方案是让每个请求都向中央实验服务请求对应的处理方式。但这会将实验平台纳入应用程序本身的可用性和延迟考量范围内,对于一个以测量为主要目的的系统而言,这是不合理的做法。
第一个版本采用确定性哈希算法。控制平面将其当前配置推送到应用程序内部的SDK中,而架构选择就在本地完成。这带来一个有用的故障特性:如果实验服务出现故障,平台暂时无法启动新实验或调整资源分配,但应用程序不会受到影响,SDK会继续使用其上一次有效的配置运行。
对于最小可行产品而言,几个随机化单元就足够了:
request
user
session
workflow_instance
最后一个存在的原因在于,代理的生命周期不一定与网页请求相同。一个代理今天可能开始仓库迁移,等待外部审批后于明天继续操作。如果根据明天的配置重新计算任务分配,同样的工作流可能会在一个实现环境中开始,却在另一个环境中结束,从而使得操作和分析变得毫无必要地复杂。因此,该任务分配会被固定不变:
workflow_instance = migration-8291
variant = B
pipeline = agent-v18
每次工作流恢复时,它都会随之一同运行。
分配与实际执行不同
当处理步骤位于智能体处理流程的某个环节时,另一个区别就显得很重要。即便被分配到某个处理分支,也不代表该处理步骤真的被执行了。
以正在测试的两种内存实现为例。用户进入的是新的内存版本B,但智能体在回应时并未进行任何内存查询。虽然分配操作确实存在,但被测试的组件根本没机会影响最终结果。这两者应当被视为不同的事件:
ASSIGNMENT
user-18271 → B
EXPOSURE
memory-v2 retrieval started
这还能提供实验健康状况信息。除了被随机分配到各组的用户数量外,还可以查看暴露情况:
assigned 10,428
exposed 8,912
exposure rate 85.46%
如果A和B的曝光率出现出乎意料的差异,首先应关注这一差异。
当分配的处理方式并非最终实际执行的方式时,会出现相关问题。假设B选择了模型Y,但模型Y返回了503错误,于是网关转而使用模型X。此时的记录显示为:
assigned_variant = B
realized_model = X
fallback_reason = provider_unavailable
执行操作仍保持在B组中。
这遵循了“意向性治疗”原则。即使实际执行的操作有所不同,也会在受试者被随机分配到的组别中进行分析。此处的默认处理方式属于B组的实验结果,若日后移除此类请求,则会依据随机化之后发生的事件来重新调整研究人群。暴露数据与实际接受的治疗数据依然具有价值,主要可作为健康状况和诊断依据,除非分析结果另有说明。
追踪技术可解释事件成因
实验结果仅能表明结果是否发生变化,却无法说明变化的原因。要探究原因,OpenTelemetry才是合理的选择,而非新的追踪协议。实验层只需为每个时间跨度附加少量额外上下文即可:
experiment.id
experiment.version
experiment.variant
experiment.assignment_id
pipeline.id
pipeline.version
execution.purpose
在完成上述设置后,将得到如下汇总结果:
B has a lower task completion rate
可以转化为一个更有用的请求:
show failed B traces
这些追踪信息能够显示新流程是否使用了质量较低的上下文、是否重试了工具调用、是否切换到了其他模型、是否陷入循环,或是只是采用了更长的路径来获得相同的结果。
不过追踪信息本身并非实验记录。它虽能很好地说明某次执行的具体过程,但实验结果可能会比追踪信息存在更久。客户今天可能与客服沟通,一周后才会续订服务,而此时最初的追踪信息早已结束。因此,在追踪功能之外还会存在一个规模较小的实验事件流:
assignment
exposure
outcome
feedback
evaluation
追踪记录了执行过程。事件流则负责将随机化操作与后续产生的结果关联起来并进行记录。
离线评估也使用相同的处理流程
在B版本面向实际用户推出之前,它必须能够应对已知的各种场景。对于智能体而言,一个场景不应简化为:
prompt
expected answer
因为有趣的结果往往不是返回的文本,而是之后的环境状态。一个支持场景可以这样写:
scenario:
id: duplicate-charge-001
input:
message: >
I was charged twice for order 9811.
environment:
fixture: duplicate-charge-customer
expected:
refund_count: 1
ticket_status: resolved
limits:
max_turns: 15
max_tool_calls: 20
max_cost: 0.50
同样的场景会在管道A和管道B上运行,可能还会重复多次,因为代理的执行是随机的,相同配置的两次运行即便从同一初始状态出发也可能走向不同的路径。MVP采用可重置的沙箱环境,配合由固定装置提供的服务或测试数据库。构建通用的多轮轨迹回放功能最好留到以后再处理。
只有当候选系统请求的交互内容已经被记录时,回放功能才能正常工作。假设在第三轮时B做出了不同的决定,并调用了原始路径从未使用过的工具,那么就没有任何已记录的状态包含该分支。此时真实的结果是:
REPLAY_DIVERGED
假装运行仍在重放而给出模拟响应是错误的做法。具有状态管理的沙箱能够让两个智能体在控制其初始条件的同时自然地分叉发展,这比简单的评估更能接近真实智能体的表现。
影子执行填补了这一空白
在受控沙箱与让用户直接接触B之间,有一个有用的步骤:在真实生产环境的输入副本上异步运行B,但不返回其输出结果。写入操作及外部副作用会被禁用或重定向到隔离环境中。这样既能提供类似生产环境的输入,又能让团队进行观察:
latency
cost
tool usage
model fallbacks
guardrail failures
judge scores
trajectory differences
在任何用户看到处理结果之前。
影子运行无法判断B是否改善了最关键的结果,因为用户从未见到B,也就无法对其作出反应。它们只是过渡手段:提供真实的输入和运行时的行为表现,同时没有实时实验所带来的产品风险。
并非每个检查都需要LLM来判断
当智能体完成一个场景后,仍需对结果进行评估,因此第一个版本设计了两种评估器。
第一种是确定性的。当任务仅涉及一次性退款并处理工单时,只需检查环境状态即可:
refund_count == 1
ticket_status == resolved
当可以检查最终状态时,再询问其他模型该任务是否可能已完成并无意义。
第二种是基于模型的方法,适用于那些难以通过断言来验证的属性:
helpfulness
clarity
tone
quality of explanation
大型语言模型法官可以提供帮助,但该法官只是另一个版本化的组件,绝非匿名的真实信息来源:
evaluator
evaluator version
judge model
judge prompt
rubric
每个生成的评分都会保留其来源信息。同时还会维护一小套由人工标注的校准数据集,并定期将评分结果与之对比。大型语言模型评分工具本质上是一种测量仪器,当该仪器本身发生变化时,其读数是否随之改变就显得非常重要。那些能够浏览、调用工具或查找额外证据的智能评分系统虽然有用,且在某些领域可能不可避免,但在第一个版本中并不适用。
精简的统计功能
成熟的实验系统往往具备极高的复杂性。相比之下,采用少量但实现正确的方法作为起点,比提供一堆很少有人能理解的复杂统计功能更为合适。第一个版本支持:
A/B allocation
binary metrics
continuous metrics
95% confidence intervals
sample ratio mismatch detection
fixed-horizon analysis
这些已经足以回答许多有用的问题,结果摘要可以这样写:
Experiment
support-agent-v18
Randomization
user
Primary metric
ticket resolution
A 81.4%
B 85.1%
Difference
+3.7 percentage points
95% CI
[...]
Experiment health
SRM PASS
样本比例不匹配需要特别关注,因为当随机化过程本身出现问题时,所得结果绝不能显得具有权威性。如果原本计划是均等分配,而实际观察到的比例明显不符,则该实验会被标记为异常状态,到此为止,不会在警告图标旁显示任何获胜者。
操作层面的约束也属于同一健康监测层面。某个变体可能在成本方面出现问题,但技术上并未失败。试想一个不断发起调用的提示循环:
4 model calls
输入到:
47 model calls
即便得到了正确的响应,状态码仍可能是HTTP 200。因此需要设置上限:
max cost / execution
max tokens / execution
max agent turns
max tool calls
max variant spend / hour
关闭上限可以终止该执行过程,或避免进一步暴露于该变种。此时的健康状况报告可能会显示如下内容:
Variant B
SRM PASS
Error rate PASS
Cost / request +312%
Circuit breaker TRIPPED
New exposure PAUSED
断路器并非某种统计结论,而是一种运营安全机制,旨在在实验尚未收集到足够数据以得出统计性结论时,防止不当的处理方式消耗流量和预算。
更复杂的方法可以稍后再用:CUPED、顺序测试、保留集、实验分层、集群随机化、自适应分配等等。数据模型应为这些方法预留空间,但第一版只需正确执行普通的A/B测试即可。
首先应构建什么
在移除了大多数可延后的功能后,该平台已变得易于管理。它需要一个控制服务来存储:
- 实验注册表
- 流水线注册表
- 数据集注册表
- 评估器注册表
- 指标定义
其上层是SDK,它负责下载实验配置,并在客户端进行确定性分配。需要一个数据接入路径用于:
assignment
exposure
outcome
feedback
evaluation
OpenTelemetry 的追踪数据也会被导入。合理的存储方案包括:用于控制平面状态的 PostgreSQL,用于事件处理的 NATS JetStream 或 Kafka,用于实验及追踪分析的 ClickHouse,以及用于存储大型数据的 S3 兼容对象存储。离线运行器会在可重置的环境中针对 A 和 B 数据集进行测试。
有很多功能是有意省略的。该最小可行产品并不支持任意的带状态环境虚拟化、实验之间的复杂交互、评估器漂移问题、自动根本原因分析、长期治疗效果分析,以及跨多个智能体的因果归因功能,而且它也不需要这些功能。从一开始就至关重要的,是那些一旦历史数据依赖于它们就难以修复的功能:
what was randomized
what treatment was assigned
what treatment actually ran
what pipeline version produced the execution
what outcome was measured
how that outcome was evaluated
只要这些标识在第一个版本中依然存在,该平台便无需改变原有实验的含义即可实现大幅扩展。
一个有价值的见解是:离线评估、影子执行和实时实验虽然各自解决不同的问题,但可以共享流程定义、追踪记录以及评估工具。离线运行能够展示A和B在受控环境中的表现;影子运行则能显示在用户不受影响的情况下B如何处理实际生产数据;实时实验则可以揭示当真实用户被随机分组时会发生什么变化。追踪记录会记载每次执行过程中的详细情况,有助于解读结果,但仅凭追踪数据所作的诊断绝不能替代因果推断。这些方法共同支撑着一个能够可靠回答三个问题的最小可行产品:
What exactly did we run?
What happened when we ran it?
Did assigning users to B improve the outcome we care about?
其他所有内容都可以围绕它发展。