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)触发。做影子流量验证时,必须解决两个核心问题:

  1. 副作用隔离(Side-Effect Isolation):主链路可能会调用真实的写操作工具(比如扣减库存、创建工单、发起转账),影子链路绝对不能执行任何破坏性副作用。
  2. 异步非阻塞执行:影子请求的推理耗时通常在数秒级别,不能占用主流程的 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:

  1. Tool Calling 决策一致性:新 Prompt 是否触发了与旧版本一致的工具集合?是否有漏调、多调或误调用高危工具的倾向。
  2. Token 消耗比(Token Overhead):新 Prompt 是否导致输出剧增?单次对话的 Prompt Tokens 和 Completion Tokens 是否超出成本预期。
  3. 响应耗时(Latency)分布:新 Prompt 会不会导致大模型进入多轮思考卡顿,P99 延迟是否明显恶化。
  4. 格式合规率(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)
}

四、生产落地的避坑经验

  1. 严格限制影子流量采样率:大模型调用是真金白银计费的。影子流量相当于额外消耗了一份 API 账单。一般情况下,新 Prompt 刚挂载时,采样率设置在 1%~5% 即可收集到足够的 Corner Case;切忌盲目开启 100% 镜像导致账单翻倍。
  2. 工具调用的隔离沙箱:必须从架构上对工具打标。只读类工具(如 query_order_status)可以直接执行;写操作类工具(如 cancel_order)在影子环境下必须拦截并注入 Mock 响应,否则生产环境会出现离奇的重复退款或重复发货事故。
  3. 冷启动与版本平滑替换:当影子流量在 48 小时内累计运行 10,000 次以上,且工具命中率 ≥ 95%、Schema 校验失败率为 0 时,再将灰度比例逐步切到主链路。这套流程让我们小团队在多次重构复杂客服 Agent 时,避免了至少 3 次全量导致的线上幻觉灾难。
Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐