如何重放一个不确定系统:从依赖注入到混合回放
LLM 的输出也是普通数据,关键是找到正确的替换边界
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 仍然会照常:
- 调用
model.request(); - 收到一个
Response; - 解析其中的文本或动作;
- 执行后续业务逻辑。
唯一省略的是那次远程 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 replay 或 record/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 次或全部替换 | 程序对指定响应的处理是正确且可重复的 | 真实模型会做出什么选择 |
| 混合回放 | 部分真实调用 | 指定分支之后,真实模型如何响应程序反馈 | 模型长期成功率 |
| 全真实运行 | 全部真实调用 | 当前这一次端到端行为 | 问题能稳定复现 |
它们不是竞争关系,而是在隔离不同变量。
比较稳妥的顺序通常是:
- 先用确定性回放验证代码;
- 再用混合回放观察模型;
- 最后按需要运行完整系统。
⚠️ 真正困难的不是注入,而是边界设计
实现一个返回固定对象的 Model 并不难。真正需要认真设计的是以下问题:
1. 在哪个调用点替换?
替换得太高,会把 Agent loop 一起 mock 掉;替换得太低,则可能绑死某个 HTTP SDK。
最合适的位置通常是应用自己的 Model 抽象层。
2. 恢复多少历史状态?
只恢复响应可能缺少上下文,恢复最终状态又可能跳过目标分支。应当恢复到目标事件发生之前。
3. 什么时候停止?
真实模型可能产生写操作。混合回放必须设置允许范围,并在越界动作执行前停止。
4. 结论如何表述?
一次回放只能证明指定输入下的行为。不要把“这一次真实模型做对了”写成“模型以后都会做对”。
🧩 这些术语是什么关系?
| 名称 | 关注点 |
|---|---|
| Dependency Injection | 用同一接口替换具体实现 |
| Test Double | 测试中替代真实依赖的对象 |
| Record/Replay | 保存历史输入或输出,之后重新使用 |
| Trace Replay | 连同历史状态或事件轨迹一起恢复 |
| Deterministic Replay | 固定非确定性输入,让执行结果可重复 |
| Hybrid Replay | 一部分使用记录,一部分调用真实依赖 |
它们描述的是同一套设计的不同侧面。
最底层的基础仍然是依赖倒置:程序面向接口工作,所以我们可以自由选择接口背后的实现。
📝 总结
所谓“直接注入 LLM 响应”,并不是修改模型,也不是伪造某种神秘内部状态。
它只是做了三件普通的软件工程工作:
- 把模型调用定义成稳定接口;
- 用本地实现代替远程实现,返回符合协议的历史响应;
- 让接口之后的真实程序继续运行。
确定性回放固定所有模型响应,用来验证代码;混合回放只固定关键响应,随后切回真实模型,用来观察模型如何处理程序反馈。
真正有价值的不是“mock 了一个 LLM”,而是找到了足够窄的替换边界:
被测逻辑保持真实,不稳定因素可以控制。
一旦理解这一点,LLM 应用的调试就不再只是反复重试和观察,而可以重新回到成熟的软件测试方法上。