让智能体记住前面的对话
复用同一个智能体实例实现连续追问,观察消息历史,区分引导与后续输入,并用正确的方式读取流式与完整结果。
让智能体记住前面的对话
如果每收到一句用户输入就重新创建一个没有历史的 Agent,第二句里的"刚才那个函数"就没有可依赖的前文。多轮对话的第一步,是让同一个会话保留消息,并在后续模型请求中继续使用。
本篇只处理进程内的对话历史,不引入数据库、向量检索或长期记忆。沿用最小项目的 runtime.ts,不开文件工具。
本文的队列语义与事件语义核对自 Pi 官方仓库 6d3e342b 的 packages/agent/src/agent.ts 与 packages/agent/src/agent-loop.ts。
用同一个实例连续提问
创建 src/conversation.ts:
import { createLabAgent, printReply } from "./runtime.js"
const agent = createLabAgent("你是中文编程助手,根据本次对话已有消息回答。")
const timer = setTimeout(() => agent.abort(), 60_000)
try {
await agent.prompt("记住这份练习代码:function first(items) { return items[0] }")
printReply(agent)
await agent.prompt("刚才的函数收到空数组会返回什么?为什么?")
printReply(agent)
console.log(
"追问后的消息角色:",
agent.state.messages.map((item) => item.role),
)
agent.reset()
console.log(
"新对话的消息角色:",
agent.state.messages.map((item) => item.role),
)
await agent.prompt("请解释 const total = 1 + 2")
printReply(agent)
} finally {
clearTimeout(timer)
agent.abort()
await agent.waitForIdle()
}运行:
node --env-file=.env --import tsx src/conversation.ts先发送函数代码,再问"刚才的函数"。第二次请求没有重复附上代码,但同一个 Agent 保存着前面的用户消息和助手消息,模型可以据此回答。
await 保证上一轮结束后再提交下一轮。运行中再次调用 prompt() 会抛异常:
// packages/agent/src/agent.ts
async prompt(input: string | AgentMessage | AgentMessage[], images?: ImageContent[]): Promise<void> {
if (this.activeRun) {
throw new Error(
"Agent is already processing a prompt. Use steer() or followUp() to queue messages, or wait for completion.",
);
}
// ...
}异常文案直接告诉你该用什么替代方案——steer() 或 followUp()。这是下面一节的内容。
历史到底保存在哪里
本例的历史在 agent.state.messages 中。打印消息角色可以看到用户和助手消息随调用增加。
但消息数不等于对话轮数乘二,至少有三个原因:
所以 state.messages 里通常至少有一条 system 消息,而且接入工具后 toolResult 会让数量继续增长。
模型服务不会因为请求来自同一段代码就自动知道本地前文。Agent Core 在构造下一次请求时使用已有消息;从应用视角看,"记得前文"来自历史的保存和重新提供。
不要只保存终端里显示的文本。接入工具后还会出现工具调用和工具结果,它们之间有结构化关联——toolResult 消息带着 toolCallId 指向发起它的那个调用。丢掉这些信息再随意拼回历史,可能让上下文失去关键执行证据。
reset() 清了什么,没清什么
reset() 的实现只有十几行,但每行都有讲究:
// packages/agent/src/agent.ts
reset(): void {
if (this.activeRun) {
throw new Error("Agent is already processing. Wait for completion before resetting.");
}
const baseline = getCurrentSystemMessage(this._state.messages);
this._state.messages = baseline ? [baseline] : [];
this._state.isStreaming = false;
this._state.streamingMessage = undefined;
this._state.pendingToolCalls = new Set<string>();
this._state.errorMessage = undefined;
this.clearFollowUpQueue();
this.clearSteeringQueue();
}三件事要记住:
第一,保留基线。getCurrentSystemMessage 重放 transcript 里的所有 system 消息,得到当前生效的那一条。重置后这条消息还在,所以系统提示和工具声明不会丢。源码注释概括为 "retaining the replayed prompt/tool baseline"。
第二,清空两个队列。引导队列和后续输入队列都会被清掉。
第三,运行中调用会抛异常。"Agent is already processing. Wait for completion before resetting." ——想切换会话,先取消并等待空闲。
引导与后续输入
运行中的智能体可以接收两类排队输入。它们都由 PendingMessageQueue 实现,结构相同,区别在于投递时机。
// 工具还在跑的时候
agent.steer({ role: "user", content: "停,先看 config.json。", timestamp: Date.now() })
// 等智能体做完手上的事
agent.followUp({ role: "user", content: "做完再总结一下。", timestamp: Date.now() })队列模式由构造参数或属性设置:
const agent = new Agent({
// ...
steeringMode: "all",
followUpMode: "one-at-a-time",
})
// 也可以随时改
agent.steeringMode = "all"两种模式的差别在 PendingMessageQueue.drain 里:
// packages/agent/src/agent.ts
peek(): AgentMessage[] {
if (this.mode === "all") return this.messages.slice();
const first = this.messages[0];
return first ? [first] : [];
}"one-at-a-time" 每轮只投一条,其余留在队列里等下一个投递点;"all" 一次性全投。
投递顺序是确定的。steering 在当前助手消息的所有工具调用完成后注入,下一轮模型才响应。follow-up 只在既没有工具调用、也没有 steering 消息时才被检查——有任何一种在排队,它就继续等。
这个顺序在循环源码里能直接看到:
// packages/agent/src/agent-loop.ts
// Agent would stop here. Check for follow-up messages.
const followUpMessages = (await config.getFollowUpMessages?.()) || [];
if (followUpMessages.length > 0) {
// Set as pending so inner loop processes them
explicitContinuation = false;
pendingMessages = followUpMessages;
continue;
}follow-up 的检查点在"智能体本来要停了"的位置,且只有在 steering 队列为空时才会走到这里。
队列还有几个管理方法:
agent.hasQueuedMessages() // 任意队列还有东西
agent.peekQueuedMessages() // 预览下一批,不消费
agent.clearSteeringQueue() // 清引导队列
agent.clearFollowUpQueue() // 清后续队列
agent.clearAllQueues() // 两个都清peekQueuedMessages() 的注释是 "Preview the messages selected for the next turn without consuming them."——它遵守队列模式,steering 有东西时返回 steering,否则返回 follow-up。
订阅事件,以及退订的正确姿势
subscribe() 返回一个退订函数:
// packages/agent/src/agent.ts
subscribe(listener: (event: AgentEvent, signal: AbortSignal) => Promise<void> | void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}返回值就是 this.listeners.delete(listener) 的结果包装。教程里 unsubscribe() 变量的正确来源就是这里:
const unsubscribe = agent.subscribe((event) => {
// ...
})
// 清理时
unsubscribe()有两个关于订阅的细节值得留意。
第一,监听器按注册顺序被 await。源码注释写着 "Listener promises are awaited in subscription order and are included in the current run's settlement." 也就是说,监听器里的异步收尾工作被计入这次运行,waitForIdle() 会等它们跑完。
第二,监听器会收到当前运行的 abort signal。回调签名是 (event, signal) => Promise<void> | void,第二个参数在取消时可以被用来中止监听器自己的收尾工作。
message_update 是累计值
message_update 事件有两部分:
// packages/agent/src/types.ts
| { type: "message_update"; message: AgentMessage; assistantMessageEvent: AssistantMessageEvent }assistantMessageEvent 里是增量(delta),message 里是截至目前的完整累计值。
拿累计值当增量用会导致重复计数。看源码就明白:streamAssistantResponse 里每次都把 partial 整体换掉,然后发出事件:
// packages/agent/src/agent-loop.ts
case "text_delta":
// ...
if (partialMessage) {
partialMessage = event.partial;
context.messages[context.messages.length - 1] = partialMessage;
await emit({
type: "message_update",
assistantMessageEvent: event,
message: { ...partialMessage },
});
}
break;而 provider 侧的 partial 是累加出来的:
// packages/ai/src/api/anthropic-messages.ts
if (open?.block.type === "text") {
open.block.text += event.delta.text;
stream.push({
type: "text_delta",
contentIndex: open.contentIndex,
delta: event.delta.text,
partial: output,
});
}所以每收到一个 text_delta,partial 里的文本就变长一截。如果你在监听器里累加 event.message 的长度,会得到平方级的重复计数。
两种正确写法:
// 写法一:只用 delta 增量,自己拼接
let buffer = ""
const unsubscribe = agent.subscribe((event) => {
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
buffer += event.assistantMessageEvent.delta
}
})
// 写法二:每次直接取累计值覆盖,不累加
let latest = ""
const unsubscribe2 = agent.subscribe((event) => {
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
latest = event.message.content
.filter((block) => block.type === "text")
.map((block) => block.text)
.join("")
}
})要拿最终确定的结果,看 message_end。message_end 发出的 message 是完整消息,没有分片边界的不确定性。message_update 只在助手消息流式期间出现。
会话历史不等于长期记忆
这段程序退出后,内存中的历史不会自动保存到文件。重新启动会创建新的 Agent,也就没有上次对话。需要跨进程恢复时,应在会话与上下文中学习存储、恢复和分支管理。
历史也不能无限增长。上下文窗口有限,持续追加会增加请求体积和成本。入门阶段先观察消息如何增长,不要急着用"只保留最后几条"解决所有问题,因为工具调用与结果可能被截断成不完整的一组——指令与消息里的 transformContext 演示了正确的裁剪位置。
怎样验证连续追问
不要只看回答"好像记住了"。先确认第二条用户消息没有再次包含函数,检查同一实例已有第一条消息,再核对空数组结论。调用 reset() 后检查旧用户消息已移除、system 基线仍在。
这些检查可以验证程序提供了哪些历史,但不能保证模型每次都正确利用前文。回答质量和历史存储是两个需要分别观察的问题。
完成后,你能控制"继续同一对话"和"开始新对话"。下一篇把进度与取消接到运行过程上,让等待状态也有明确反馈。