Agent 影子流量(Shadow Traffic)验证:在生产环境中无感对比新旧 Prompt 效果
Agent 影子流量(Shadow Traffic)验证:在生产环境中无感对比新旧 Prompt 效果

在传统微服务架构里,做接口重构或升级底层组件时,我们经常使用“流量镜像(Traffic Mirroring / Shadow Traffic)”来做验证。把线上的真实请求无感复制一份打到待上线的候选版本上,既不影响主链路响应时间,又能拿到最真实的生产流量进行压力和正确性验证。
当我们把 AI Agent 推向生产环境时,Prompt 的迭代升级成了一个高频且高危的动作。业务方或者产品经理经常会说:“我调优了一版 System Prompt,在几个测试用例里表现很好,咱们直接全量吧。”——但凡踩过坑的老工程师都知道,LLM 具有极强的非确定性(Non-deterministic),针对某个 Corner Case 优化的 Prompt,极有可能在另一个原本正常的常见场景里引发严重的退化(Regression)或者死循环工具调用。
在小厂资源受限的背景下,搭建复杂的离线评测标注平台成本太高,最接地气且 ROI 最高的方案就是:在 Go 网关层或 Agent 调度编排层直接引入影子流量验证机制。
一、影子流量在 Agent 系统的整体架构
在 Agent 架构中,一个用户请求可能包含长上下文、多轮对话历史以及潜在的外部工具(Tools / Plugins)触发。做影子流量验证时,必须解决两个核心问题:
- 副作用隔离(Side-Effect Isolation):主链路可能会调用真实的写操作工具(比如扣减库存、创建工单、发起转账),影子链路绝对不能执行任何破坏性副作用。
- 异步非阻塞执行:影子请求的推理耗时通常在数秒级别,不能占用主流程的 Goroutine 和连接池,必须优雅降级并异步处理。
下面是我们在 Go 后端网关中落地的影子流量转发与对比流水线设计:
+--------------------+
| User Request |
+---------+----------+
|
[ API Gateway / BFF ]
|
+------------------+------------------+
| (Sync) | (Async Goroutine / Worker Pool)
v v
+-----------------------+ +-----------------------+
| Active Agent Worker | | Shadow Agent Worker |
| (Production Prompt) | | (Candidate Prompt) |
+-----------+-----------+ +-----------+-----------+
| |
| Real Tool Execution | Mock / Dry-run Tools Only
v v
+-----------------------+ +-----------------------+
| Return User Response | | Shadow Execution Log |
+-----------------------+ +-----------+-----------+
|
v
+-----------------------+
| Async Diff & Metrics |
| (Tool Calls / Cost) |
+-----------------------+
二、核心 Go 影子分流调度器实现
在 Go 中实现影子分流,最直接的方式是结合 context.WithoutCancel(Go 1.21+)或构造一个独立的影子上下文,将请求上下文剥离超时取消信号后,投递给异步工作池(Worker Pool),避免因为主请求结束导致影子任务被意外中断。
package agent
import (
"context"
"encoding/json"
"log"
"math/rand"
"sync"
"time"
)
// AgentRequest 定义用户发起的 Agent 对话请求
type AgentRequest struct {
SessionID string `json:"session_id"`
UserID string `json:"user_id"`
Messages []ChatMessage `json:"messages"`
Metadata map[string]string `json:"metadata"`
}
type ChatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
// AgentResponse 定义 Agent 返回的结构体
type AgentResponse struct {
ReplyText string `json:"reply_text"`
ToolCalls []string `json:"tool_calls"`
TotalTokens int `json:"total_tokens"`
LatencyMs int64 `json:"latency_ms"`
}
type ShadowDispatcher struct {
sampleRate float64 // 采样率 0.0 ~ 1.0
workerChan chan shadowTask
closeOnce sync.Once
}
type shadowTask struct {
req AgentRequest
prodResp AgentResponse
prodPrompt string
shadowPrompt string
}
func NewShadowDispatcher(sampleRate float64, queueSize int, workerCount int) *ShadowDispatcher {
d := &ShadowDispatcher{
sampleRate: sampleRate,
workerChan: make(chan shadowTask, queueSize),
}
for i := 0; i < workerCount; i++ {
go d.workerLoop()
}
return d
}
// DispatchSync 处理主请求并决定是否派发影子任务
func (d *ShadowDispatcher) DispatchSync(ctx context.Context, req AgentRequest, prodPrompt, shadowPrompt string) (AgentResponse, error) {
start := time.Now()
// 1. 执行主链路(生产版本)
prodResp, err := executeAgentPipeline(ctx, req, prodPrompt, false)
if err != nil {
return AgentResponse{}, err
}
prodResp.LatencyMs = time.Since(start).Milliseconds()
// 2. 判断是否命中影子采样
if shadowPrompt != "" && rand.Float64() < d.sampleRate {
task := shadowTask{
req: req,
prodResp: prodResp,
prodPrompt: prodPrompt,
shadowPrompt: shadowPrompt,
}
select {
case d.workerChan <- task:
// 投递成功
default:
// 队列已满,直接丢弃影子流量,绝不反压主链路
log.Println("[ShadowDispatcher] Queue full, drop shadow task")
}
}
return prodResp, nil
}
func (d *ShadowDispatcher) workerLoop() {
for task := range d.workerChan {
d.processShadowTask(task)
}
}
func (d *ShadowDispatcher) processShadowTask(task shadowTask) {
// 影子链路使用独立超时控制,且开启 Dry-Run 模式
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
start := time.Now()
shadowResp, err := executeAgentPipeline(ctx, task.req, task.shadowPrompt, true)
if err != nil {
log.Printf("[ShadowDiff] Session %s shadow execution error: %v", task.req.SessionID, err)
return
}
shadowResp.LatencyMs = time.Since(start).Milliseconds()
// 进行指标与差异对比
diffResult := compareExecution(task.prodResp, shadowResp)
recordShadowMetrics(task.req.SessionID, diffResult)
}
func executeAgentPipeline(ctx context.Context, req AgentRequest, prompt string, dryRun bool) (AgentResponse, error) {
// 模拟 Agent 执行逻辑
// 当 dryRun 为 true 时,所有下游 Tool 调用只打印参数并返回模拟数据,不写数据库/第三方 API
_ = dryRun
return AgentResponse{
ReplyText: "执行完成",
ToolCalls: []string{"query_user_info"},
TotalTokens: 850,
}, nil
}
三、影子结果评估与差异对比维度
大模型生成的内容具有语义漂移性,不能简单用 strings.Equal 去判断两者的好坏。在生产实践中,我们主要关注以下四个客观维度的 Diff:
- Tool Calling 决策一致性:新 Prompt 是否触发了与旧版本一致的工具集合?是否有漏调、多调或误调用高危工具的倾向。
- Token 消耗比(Token Overhead):新 Prompt 是否导致输出剧增?单次对话的 Prompt Tokens 和 Completion Tokens 是否超出成本预期。
- 响应耗时(Latency)分布:新 Prompt 会不会导致大模型进入多轮思考卡顿,P99 延迟是否明显恶化。
- 格式合规率(Schema Validation):若要求输出 JSON,新版本在真实流量下的解析失败率是否上升。
type DiffResult struct {
SessionID string
ToolMatchRate float64
TokenDelta int
LatencyDeltaMs int64
IsSchemaValid bool
}
func compareExecution(prod, shadow AgentResponse) DiffResult {
// 计算 Tool 调用的重合度 (Jaccard 相似度)
toolMatch := calculateToolJaccard(prod.ToolCalls, shadow.ToolCalls)
return DiffResult{
ToolMatchRate: toolMatch,
TokenDelta: shadow.TotalTokens - prod.TotalTokens,
LatencyDeltaMs: shadow.LatencyMs - prod.LatencyMs,
IsSchemaValid: isValidJSON(shadow.ReplyText),
}
}
func calculateToolJaccard(a, b []string) float64 {
if len(a) == 0 && len(b) == 0 {
return 1.0
}
set := make(map[string]int)
for _, v := range a {
set[v] |= 1
}
for _, v := range b {
set[v] |= 2
}
intersection, union := 0, len(set)
for _, v := range set {
if v == 3 {
intersection++
}
}
return float64(intersection) / float64(union)
}
func isValidJSON(str string) bool {
var js json.RawMessage
return json.Unmarshal([]byte(str), &js) == nil
}
func recordShadowMetrics(sessionID string, diff DiffResult) {
log.Printf("[Metrics] Session: %s | ToolMatch: %.2f | TokenDelta: %+d | LatencyDelta: %+dms",
sessionID, diff.ToolMatchRate, diff.TokenDelta, diff.LatencyDeltaMs)
}
四、生产落地的避坑经验
- 严格限制影子流量采样率:大模型调用是真金白银计费的。影子流量相当于额外消耗了一份 API 账单。一般情况下,新 Prompt 刚挂载时,采样率设置在 1%~5% 即可收集到足够的 Corner Case;切忌盲目开启 100% 镜像导致账单翻倍。
- 工具调用的隔离沙箱:必须从架构上对工具打标。只读类工具(如
query_order_status)可以直接执行;写操作类工具(如cancel_order)在影子环境下必须拦截并注入 Mock 响应,否则生产环境会出现离奇的重复退款或重复发货事故。 - 冷启动与版本平滑替换:当影子流量在 48 小时内累计运行 10,000 次以上,且工具命中率 ≥ 95%、Schema 校验失败率为 0 时,再将灰度比例逐步切到主链路。这套流程让我们小团队在多次重构复杂客服 Agent 时,避免了至少 3 次全量导致的线上幻觉灾难。
更多推荐


所有评论(0)