跳到主内容
创建时间:最后更新:作者:白川

让智能体记住前面的对话

复用同一个智能体实例实现连续追问,观察消息历史,区分引导与后续输入,并用正确的方式读取流式与完整结果。

让智能体记住前面的对话

如果每收到一句用户输入就重新创建一个没有历史的 Agent,第二句里的"刚才那个函数"就没有可依赖的前文。多轮对话的第一步,是让同一个会话保留消息,并在后续模型请求中继续使用。

本篇只处理进程内的对话历史,不引入数据库、向量检索或长期记忆。沿用最小项目的 runtime.ts,不开文件工具。

本文的队列语义与事件语义核对自 Pi 官方仓库 6d3e342b 的 packages/agent/src/agent.ts 与 packages/agent/src/agent-loop.ts。

用同一个实例连续提问

创建 src/conversation.ts:

typescript
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()
}

运行:

bash
node --env-file=.env --import tsx src/conversation.ts

先发送函数代码,再问"刚才的函数"。第二次请求没有重复附上代码,但同一个 Agent 保存着前面的用户消息和助手消息,模型可以据此回答。

await 保证上一轮结束后再提交下一轮。运行中再次调用 prompt() 会抛异常:

typescript
// 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 中。打印消息角色可以看到用户和助手消息随调用增加。

但消息数不等于对话轮数乘二,至少有三个原因:

消息角色什么时候进入列表
system
构造时种下的前导 system 消息,整个会话都在
user
每次 prompt() 追加一条
assistant
每次模型响应追加一条
toolResult
接入工具后,每个工具结果追加一条

所以 state.messages 里通常至少有一条 system 消息,而且接入工具后 toolResult 会让数量继续增长。

模型服务不会因为请求来自同一段代码就自动知道本地前文。Agent Core 在构造下一次请求时使用已有消息;从应用视角看,"记得前文"来自历史的保存和重新提供。

不要只保存终端里显示的文本。接入工具后还会出现工具调用和工具结果,它们之间有结构化关联——toolResult 消息带着 toolCallId 指向发起它的那个调用。丢掉这些信息再随意拼回历史,可能让上下文失去关键执行证据。

reset() 清了什么,没清什么

reset() 的实现只有十几行,但每行都有讲究:

typescript
// 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 实现,结构相同,区别在于投递时机。

方法投递时机队列模式可选值
steer()
当前助手轮次之后——"这一轮工具跑完了,但接着来"
"one-at-a-time"(默认)、"all"
followUp()
智能体把待办工作都做完之后
"one-at-a-time"(默认)、"all"
typescript
// 工具还在跑的时候
agent.steer({ role: "user", content: "停,先看 config.json。", timestamp: Date.now() })

// 等智能体做完手上的事
agent.followUp({ role: "user", content: "做完再总结一下。", timestamp: Date.now() })

队列模式由构造参数或属性设置:

typescript
const agent = new Agent({
	// ...
	steeringMode: "all",
	followUpMode: "one-at-a-time",
})
// 也可以随时改
agent.steeringMode = "all"

两种模式的差别在 PendingMessageQueue.drain 里:

typescript
// 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 消息时才被检查——有任何一种在排队,它就继续等。

这个顺序在循环源码里能直接看到:

typescript
// 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 队列为空时才会走到这里。

队列还有几个管理方法:

typescript
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() 返回一个退订函数:

typescript
// 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() 变量的正确来源就是这里:

typescript
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 事件有两部分:

typescript
// packages/agent/src/types.ts
| { type: "message_update"; message: AgentMessage; assistantMessageEvent: AssistantMessageEvent }

assistantMessageEvent 里是增量(delta),message 里是截至目前的完整累计值。

拿累计值当增量用会导致重复计数。看源码就明白:streamAssistantResponse 里每次都把 partial 整体换掉,然后发出事件:

typescript
// 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 是累加出来的:

typescript
// 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 的长度,会得到平方级的重复计数。

两种正确写法:

typescript
// 写法一:只用 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 基线仍在。

这些检查可以验证程序提供了哪些历史,但不能保证模型每次都正确利用前文。回答质量和历史存储是两个需要分别观察的问题。

完成后,你能控制"继续同一对话"和"开始新对话"。下一篇把进度与取消接到运行过程上,让等待状态也有明确反馈。

参考资料