
一次 Agent 运行里的历史,常常同时承担两件事:当前轮次继续追加消息,另一份数据则要留作检查点或回滚基线。写成 const checkpoint = [...history] 看起来像是保存了一份副本,实际上只复制了数组外壳。数组里的消息对象仍指向同一块内存,消息中的 content 数组也没有复制。只要后续对其中一条消息做原地改写,两个变量看到的内容都会变。
这不只影响调试日志。检查点若被误改,恢复时拿到的就不再是保存当时的历史;而当前请求组装输入时,也可能把本应留存的旧内容带着一起改掉。表面上是「保存前后内容不同」,根源却是对象共享,并非保存动作漏写。把数组长度、消息数量或外层引用分别打印出来,仍可能看见完全正常的结果。还要区分追加与改写:history.push(newMessage) 只动当前数组,浅复制的检查点长度不会跟着变;若改的是已有消息内部字段,两边才同时显出变化。只测新增消息,容易误以为整个快照已经隔离。
外层副本挡不住嵌套引用
用一条带数组内容的消息就能看清关系。浅复制之后,修改副本里的文本,原数组也会显示新文本。代码不需要模型服务,也不涉及任何特定 SDK,展示的只是 JavaScript 对象引用规则。
const history = [
{ role: 'assistant', content: [{ type: 'text', text: '准备调用工具' }] },
];
const checkpoint = [...history];
checkpoint[0].content[0].text = '改写后的文本';
console.log(history[0].content[0].text);
这里的副本仍指向同一条消息,没有形成独立历史。Agent 里常见的改写包括把工具调用结果并回消息、截断旧上下文,或给消息补上执行状态。只要做法是原地改字段,先前保存的数组就会跟着变化。若问题只在局部出现,日志可能先后打印同一个数组变量,输出却让人误以为保存过程有竞态;实际上,两个名字一直指向同一批对象。
深拷贝让检查点保留当时状态
在 Node.js 支持 structuredClone 的运行环境里,可以复制整棵数据结构。对普通消息记录,复制后的对象与源对象互相独立,改动一边不会穿透到另一边。测试结果也显示,浅复制会让嵌套文本回写到原历史,而结构化克隆把修改留在检查点内;反向再改源记录,检查点仍保留原值。
SHALLOW COPY: nested mutation reached live history
STRUCTURED CLONE: nested mutation stayed in checkpoint
REVERSE CHECK: later source mutation stayed out of checkpoint
ASSERTIONS: all passed
const checkpoint = structuredClone(history);
checkpoint[0].content[0].text = '检查点里的改动';
深拷贝不是所有数据的通用按钮。若历史对象里放了函数、带私有状态的实例或刻意共享的缓存,结构化克隆可能拒绝,或不符合原有语义。更稳妥的边界是把消息历史限定为可序列化的数据记录,再在创建检查点时整体复制;运行期需要共享的对象放在历史之外,不把它们悄悄塞进消息字段。
另一个容易忽略的时刻是保存动作本身。若检查点交给异步持久化之前仍共享对象,调用方在写盘期间继续改消息,最终落下的内容未必对应调用时刻。先生成独立快照,再交给序列化或存储层,边界会清楚很多。历史很大时,也可以只在需要回滚的节点保存快照,普通追加则采用不可变消息或复制被修改的那一条;这属于性能取舍,不能用浅拷贝的名字冒充隔离。
我倾向于把检查点当成时间切片,而不是另一个可编辑视图。对短消息历史,直接深拷贝更容易审计;若性能数据证明它太贵,再改为结构共享加不可变更新,前提是写入路径确实不原地改对象。模型输出和具体框架的历史对象是否满足结构化克隆条件没有在这里实测,接入前应检查实际数据形状。只要检查点要承担恢复责任,它就必须与正在运行的那份历史真正分开。