如何重放一个不确定系统:从依赖注入到混合回放

LLM 的输出也是普通数据,关键是找到正确的替换边界

August 13, 2026·5 min read·Yimin
#LLM#AI Agent#软件测试#依赖注入#Replay

LLM 看起来很神秘,但对承载它的程序来说,模型调用仍然只是一个普通函数:输入消息,返回数据。只要守住这份接口契约,我们就能替换模型,却保留模型之外的整个系统。

🎯 为什么 LLM 系统特别难调试?

传统程序通常比较确定。同样的输入和状态,代码大概率会走进同一个分支。

LLM 不一样。即使输入完全相同,它也可能在两次运行中给出不同措辞、不同参数,甚至选择不同动作。

这会产生一个很棘手的问题:

线上曾经出现过一次错误输出,但修改代码以后,我们无法保证模型愿意再次犯同一个错误。

如果只是反复调用模型,验证结果会混在两种变化里:

  • 代码变了;
  • 模型这次的输出也变了。

最后即使测试通过,你也很难知道究竟是哪一个因素起了作用。

确定性回放要做的,就是先固定其中一个变量。


🧠 先祛魅:LLM 调用只是一个接口

从应用程序角度看,一次模型调用可以简化成一个函数:

model.request(messages, tools, settings) → response

输入通常包括:

  • 消息历史;
  • 系统指令;
  • 可调用工具;
  • 模型参数。

输出通常包括:

  • 文本;
  • 结构化数据;
  • 工具调用;
  • token 使用量等元数据。

远程 LLM 只是这份接口的一种实现:

程序
  ↓
Model 接口
  ↓
HTTP Client
  ↓
远程 LLM
  ↓
解析 HTTP 响应
  ↓
Response 对象

最重要的是最后一步。Agent 真正拿到的不是“某个神秘模型”,而是一个普通的 Response 对象。

既然如此,我们当然可以提供另一种实现:

程序
  ↓
Model 接口
  ↓
本地 Replay Model
  ↓
直接返回预先保存的 Response 对象

两条路径的终点完全相同。对于上层 Agent 来说,它们没有本质区别。


⚙️ “直接写入模型响应”到底是什么意思?

严格来说,它不是把内容“写进 LLM”,也不是篡改网络请求。

更准确的说法是:

用一个本地实现替换真实 Model,实现同一个接口,并直接返回预先准备的响应。

假设真实模型接口是:

# model.py
class Model:
    async def request(self, messages) -> Response:
        ...

远程模型可能这样实现:

# remote_model.py
class RemoteModel(Model):
    async def request(self, messages) -> Response:
        raw = await http_client.post("https://example.com", json=messages)
        return Response.parse(raw)

回放模型则可以这样实现:

# replay_model.py
class ReplayModel(Model):
    def __init__(self, recorded_response):
        self.recorded_response = recorded_response

    async def request(self, messages) -> Response:
        return self.recorded_response

运行测试时,把 Agent 原本依赖的 RemoteModel 临时替换成 ReplayModel

# test_replay.py
agent = Agent(model=ReplayModel(recorded_response))
await agent.run()

Agent 仍然会照常:

  1. 调用 model.request()
  2. 收到一个 Response
  3. 解析其中的文本或动作;
  4. 执行后续业务逻辑。

唯一省略的是那次远程 HTTP 调用。

这不是 LLM 特有的黑魔法,而是经典的 依赖注入:调用者依赖接口,不依赖某个具体实现。


🔍 为什么返回一段 JSON 还不够?

真实系统通常不会直接把一段字符串交给 Agent,而是先解析成带类型的对象。

例如一段工具调用可能包含:

Response
└── parts
    ├── TextPart
    └── ToolCallPart
        ├── name
        ├── arguments
        └── call_id

因此,回放时需要恢复的是框架认识的响应结构,而不是随便拼一段文本。

常见做法有两种:

方式一:保存序列化结果

线上运行时把 Response 序列化为 JSON:

Response 对象 → JSON → 文件或日志

回放时再反序列化:

JSON → 类型校验 → Response 对象

方式二:在测试中构造对象

如果场景很小,也可以直接构造:

# fixture.py
recorded_response = Response(
    parts=[
        ToolCallPart(
            name="some_action",
            arguments={"value": 42},
        )
    ]
)

无论采用哪一种,关键都不是内容来自哪里,而是它是否满足 Model 接口承诺的输出协议。


🏛️ 为什么替换模型后,后面的系统仍然是真的?

因为替换发生在一个很窄的边界上。

             被替换
                ↓
输入状态 → [ Model ] → 响应解析 → 动作校验 → 状态变更 → 下一轮
                           ↑____________________________↑
                                  全部真实运行

只要没有继续 mock 后面的组件,那么这些逻辑仍然会真实执行:

  • Agent loop;
  • 响应解析;
  • 工具选择;
  • 参数校验;
  • 错误反馈;
  • 状态机迁移;
  • 下一轮消息组装。

这也是它与“直接测试一个校验函数”的区别。

直接调用校验函数只能证明一条规则正确;在模型边界做回放,则可以验证这条规则是否真的被完整 Agent 流程触发。


🧩 状态回放:只恢复响应还不一定够

很多 Agent 的下一步行为不只取决于模型响应,还取决于当时的状态:

  • 消息历史;
  • 工作流进度;
  • 已加载能力;
  • 临时变量;
  • 上一轮工具结果;
  • 用户环境。

因此更完整的 replay 通常包含两类材料:

历史状态 Snapshot + 某一次模型 Response

Snapshot 负责恢复“当时系统知道什么”,Response 负责恢复“当时模型说了什么”。

Snapshot ───────→ 恢复程序状态
Recorded Response → 替换某次模型返回
                         ↓
                  从目标位置继续运行

这里并不是把整个线上系统复制一份,而是恢复能够影响目标分支的最小状态。

恢复太少,程序走不到原来的位置;恢复太多,又可能把问题发生后的结果也带回来。好的 replay 工具需要明确“从哪个时间点继续”。


⚙️ 确定性回放:所有模型输出都固定

如果我们希望只验证程序逻辑,可以把每一次模型响应都固定下来:

第 1 次 request → Recorded Response A
第 2 次 request → Recorded Response B

一个最简单的实现就是维护调用计数:

# sequence_model.py
class SequenceModel(Model):
    def __init__(self, responses):
        self.responses = responses
        self.index = 0

    async def request(self, messages) -> Response:
        response = self.responses[self.index]
        self.index += 1
        return response

这样,同一组输入每次都会经过完全相同的路径。

它可以回答:

  • 历史响应能否触发目标分支?
  • 程序产生的反馈是否正确?
  • 下一份响应是否能继续推进状态?
  • 整个过程有没有意外副作用?

这种模式通常称为 deterministic replayrecord/replay testing


🚀 混合回放:先固定,再切回真实模型

有时我们不仅想验证代码,还想知道真实模型能否根据程序反馈自行调整。

这时可以让模型实现分阶段工作:

# hybrid_model.py
class HybridModel(Model):
    def __init__(self, recorded_response, real_model):
        self.recorded_response = recorded_response
        self.real_model = real_model
        self.first_call = True

    async def request(self, messages) -> Response:
        if self.first_call:
            self.first_call = False
            return self.recorded_response

        return await self.real_model.request(messages)

流程会变成:

第 1 轮:历史响应直接返回
              ↓
        程序真实处理并产生反馈
              ↓
第 2 轮:完整上下文发给真实 LLM
              ↓
        观察模型如何继续

第一轮保证问题一定出现,第二轮则保留模型的真实性。

这就是 hybrid replay:同一条执行链中,一部分响应来自记录,一部分响应来自真实服务。


🔍 两种模式分别证明什么?

模式模型调用能证明什么不能证明什么
确定性回放0 次或全部替换程序对指定响应的处理是正确且可重复的真实模型会做出什么选择
混合回放部分真实调用指定分支之后,真实模型如何响应程序反馈模型长期成功率
全真实运行全部真实调用当前这一次端到端行为问题能稳定复现

它们不是竞争关系,而是在隔离不同变量。

比较稳妥的顺序通常是:

  1. 先用确定性回放验证代码;
  2. 再用混合回放观察模型;
  3. 最后按需要运行完整系统。

⚠️ 真正困难的不是注入,而是边界设计

实现一个返回固定对象的 Model 并不难。真正需要认真设计的是以下问题:

1. 在哪个调用点替换?

替换得太高,会把 Agent loop 一起 mock 掉;替换得太低,则可能绑死某个 HTTP SDK。

最合适的位置通常是应用自己的 Model 抽象层。

2. 恢复多少历史状态?

只恢复响应可能缺少上下文,恢复最终状态又可能跳过目标分支。应当恢复到目标事件发生之前。

3. 什么时候停止?

真实模型可能产生写操作。混合回放必须设置允许范围,并在越界动作执行前停止。

4. 结论如何表述?

一次回放只能证明指定输入下的行为。不要把“这一次真实模型做对了”写成“模型以后都会做对”。


🧩 这些术语是什么关系?

名称关注点
Dependency Injection用同一接口替换具体实现
Test Double测试中替代真实依赖的对象
Record/Replay保存历史输入或输出,之后重新使用
Trace Replay连同历史状态或事件轨迹一起恢复
Deterministic Replay固定非确定性输入,让执行结果可重复
Hybrid Replay一部分使用记录,一部分调用真实依赖

它们描述的是同一套设计的不同侧面。

最底层的基础仍然是依赖倒置:程序面向接口工作,所以我们可以自由选择接口背后的实现。


📝 总结

所谓“直接注入 LLM 响应”,并不是修改模型,也不是伪造某种神秘内部状态。

它只是做了三件普通的软件工程工作:

  1. 把模型调用定义成稳定接口;
  2. 用本地实现代替远程实现,返回符合协议的历史响应;
  3. 让接口之后的真实程序继续运行。

确定性回放固定所有模型响应,用来验证代码;混合回放只固定关键响应,随后切回真实模型,用来观察模型如何处理程序反馈。

真正有价值的不是“mock 了一个 LLM”,而是找到了足够窄的替换边界:

被测逻辑保持真实,不稳定因素可以控制。

一旦理解这一点,LLM 应用的调试就不再只是反复重试和观察,而可以重新回到成熟的软件测试方法上。