发送聊天消息时,流程比之前多了几步。
在没有记忆系统之前,聊天发送链路比较直接:前端把当前 UI 里的 messages 发给后端,后端把这些消息转成模型输入,然后把模型输出流式返回给前端。这个流程能跑起来,但它有一个明显限制:上下文主要来自前端当前状态。
接入记忆系统以后,发送链路要发生变化。后端不能只相信前端传来的消息列表,而是要把服务端已经保存的历史消息、会话摘要和长期记忆一起纳入考虑。也就是说,前端仍然负责提交本轮用户输入和必要的会话信息,但真正参与 prompt 组装的上下文,应该以服务端持久化数据为准。
这样处理以后,聊天状态会稳定很多。用户刷新页面以后,前端 messages 会重新初始化;用户切换 Agent 后,前端状态也会变化;甚至用户打开多个标签页时,不同页面里的 UI 状态都可能不一致。服务端 D1 里的历史消息才是统一来源。发送时回到 D1 读取上下文,能让聊天行为不依赖某一次页面状态。
01用户发送消息02↓03API 校验 token04↓05找到用户拥有的 Agent06↓07获取默认 conversation08↓09写入用户消息10↓11读取最近历史消息12↓13读取长期记忆14↓15组装 LLM prompt16↓17调用上游 LLM18↓19流式返回给前端20↓21流结束后写入 assistant 消息22↓23更新 summary、message_count、last_message_at_ms24↓25抽取长期记忆
这张流程图里,本文先讲到 prompt 组装为止。流式返回、assistant 消息落库、摘要更新和长期记忆抽取属于模型回复后的处理,放在下一段链路里看会更清楚。
用户消息会在调用 LLM 前先写入 D1:
我们需要先讨论写入顺序。直觉上,我们可能会等 LLM 成功返回以后,再把用户消息和 assistant 回复一起保存。这样写起来似乎更整齐,但会带来一个体验问题:如果上游 LLM 调用失败,用户刚刚输入的内容也会丢掉。用户明明已经发送了一条消息,刷新后却看不到它,这会让聊天历史不可信。
因此这里选择先保存用户消息。只要用户请求已经通过鉴权,并且能确认当前 Agent 和 conversation,就先把这条用户消息写入 agent_conversation_messages。后续 LLM 成功时,再补 assistant 消息;如果 LLM 失败,用户消息仍然留在历史里,页面列表也可以显示用户刚刚发出的内容。
01if (agentId && conversationId && latestUserText) {02sourceUserMessageId = crypto.randomUUID()03const userMessageNowMs = Date.now()0405await insertAgentConversationMessage({06db,07id: sourceUserMessageId,08conversationId,09userId: claims.sub,10agentId,11role: 'user',12content: latestUserText,13status: 'completed',14nowMs: userMessageNowMs,15})1617await updateAgentConversationAfterMessage({18db,19userId: claims.sub,20agentId,21conversationId,22summary: ownedConversation?.conversation.summary ?? null,23messageCount: (ownedConversation?.conversation.messageCount ?? 0) + 1,24lastMessageAtMs: userMessageNowMs,25nowMs: userMessageNowMs,26})27}
如果上游 LLM 调用失败,用户刚刚发出的消息也不会丢。它仍然会成为历史消息的一部分。
这里还同步更新了 conversation 的 messageCount 和 lastMessageAtMs。这一步不是为了 prompt,而是为了会话状态。用户消息一旦写入,会话就已经发生变化。即使 assistant 还没有返回,首页列表也应该知道这个 Agent 刚刚有新互动。lastMessageAtMs 会影响排序,messageCount 会影响后续会话统计和摘要判断。
sourceUserMessageId 也在这里保存下来。它后面会用于长期记忆的来源关联。比如从本轮用户消息里抽取出一条偏好,就可以把记忆的 sourceMessageId 指向这条用户消息。这样记忆库页面以后就能追溯:这条记忆是从哪条对话里来的。
当然,并不是所有边缘路径都一定能写入用户消息。比如缺少 agentId 或 conversationId 时,代码会走兼容逻辑。正因为存在这些边缘情况,后面更新消息数时才不能简单固定 +2,而要根据是否真的写入过用户消息来决定。
调用 LLM 之前,后端会读取两类上下文。
用户消息落库以后,就要准备模型输入。这里不直接使用前端传来的全部 messages,而是从服务端读取最近历史和长期记忆。前端传来的消息仍然有价值,它能提供当前 UI 的最新输入,也能作为 contracts 的必要字段;但服务端上下文应该由服务端自己确认。
最近聊天消息解决的是当前对话连贯性。用户前几轮说了什么,assistant 刚刚怎么回答,当前问题接着哪句话而来,这些都需要最近消息来支撑。没有最近消息,模型很容易把本轮输入当成孤立问题来回答。
最近聊天消息:
1const storedRecentMessages = agentId && conversationId2? await listAgentConversationMessages({3db,4userId: claims.sub,5agentId,6conversationId,7limit: recentMessageLimit,8})9: []
长期记忆:
长期记忆解决的是跨会话稳定偏好。它不一定在最近几条消息里出现,但会影响回答方式。比如用户曾经表达过不喜欢过度分析,或者希望回复更自然直接,这类记忆就应该在每次聊天时被优先尊重。
1const activeMemories = agentId2? await listActiveAgentMemories({3db,4userId: claims.sub,5agentId,6limit: memoryInjectionLimit,7})8: []
长期记忆按重要度和更新时间排序:
1.orderBy(sql`${agentMemories.importance} desc, ${agentMemories.updatedAtMs} desc`)
这样排序以后,重要记忆会优先进入 prompt。
这里按 importance desc 和 updatedAtMs desc 排序,是第一版没有向量检索时的简单策略。重要度高的记忆优先进入 prompt;重要度相同,则更新更近的记忆优先。这个方案不一定永远最准确,但在记忆数量不多时非常直接,也容易解释给用户。
我们还要注意一个边界:长期记忆不是越多越好。每条记忆都会占用 prompt 空间,如果无节制地注入,反而会挤掉最近消息和用户本轮输入。第一版用 memoryInjectionLimit 控制数量,就是为了让长期记忆参与回答,但不主导整个上下文。
系统 prompt 里会注入 Agent 默认人设、长期记忆和会话摘要:
prompt 组装决定模型最终能看到什么,也决定 Agent 的回答风格是否稳定。这里采用的顺序是先系统级约束,再聊天对象资料,再历史消息,最后是本轮用户输入。
系统 prompt 的第一层是 Agent 默认人设。它描述这个 Agent 应该如何回答、应该保持什么语气、应该避免哪些风险。长期记忆和会话摘要也放在系统消息里,是为了让模型把它们当作更高优先级的背景信息,而不是普通用户输入。
01const messages: ChatCompletionMessage[] = [02{03role: 'system',04content: [05agentPrompt?.defaultPrompt || '你是 AI Agent Web 控制台里的聊天陪伴助手。',06'请基于当前聊天对象、关系氛围和用户意图,用简洁、自然的中文回答用户。',07'如果用户要求起草回复,请直接给出可发送的聊天内容,避免正式公文格式和职场汇报语气。',08'你的建议应尊重双方边界,避免操控式话术、制造焦虑或诱导过度解读。',09activeMemories.length > 010? [11'以下是用户与该 Agent 的长期记忆,请优先尊重:',12...activeMemories.map((memory) => `- [${memory.type} / 重要度 ${memory.importance}] ${memory.content}`),13].join('\n')14: '',15ownedConversation?.conversation.summary16? `此前对话摘要:${ownedConversation.conversation.summary}`17: '',18].join('\n'),19},20]
然后追加当前聊天对象资料:
当前聊天对象资料单独作为一条 user message 追加进去,是为了让模型知道这次对话面对的是哪个 Agent,以及这个 Agent 的关系阶段、主题、节奏、备注等信息。它不是长期记忆,也不是聊天历史,而是当前聊天对象的静态上下文。
01messages.push({02role: 'user',03content: [04`聊天对象:${payload.conversation.name}(${payload.conversation.handle})`,05`对象状态:${payload.conversation.status},${payload.conversation.lastActive}`,06`关系阶段:${payload.conversation.relationship}`,07`聊天主题:${payload.conversation.headline}`,08`共同点:${payload.conversation.topic}`,09`心动值:${payload.conversation.chemistryLabel} ${payload.conversation.chemistry}`,10`互动节奏:${payload.conversation.rhythm}`,11`对象备注:${payload.conversation.profileNote}`,12].join('\n'),13})
最后追加历史消息和本轮用户输入:
历史消息来自 D1 中保存的最近消息。追加时会先通过 normalizeStoredMessage 清理内容,空消息会跳过。这样可以减少异常数据进入 prompt 的机会。历史消息保留原来的 role,用户消息仍然是 user,assistant 回复仍然是 assistant,模型就能理解对话轮次。
01for (const message of promptHistory) {02const text = normalizeStoredMessage(message.content)0304if (!text) {05continue06}0708messages.push({ role: message.role, content: text })09}1011if (storedRecentMessages.length > 0 && latestUserText) {12messages.push({ role: 'user', content: latestUserText })13}
这里有一个细节:前端虽然也会传 messages,但后端优先使用 D1 中保存的历史。这样用户刷新、切换 Agent、加载历史后,真正决定上下文的是服务端持久化数据,而不是前端临时状态。
这里需要再强调一次:前端消息和服务端历史可能短暂不一致,尤其在刚发送、刚刷新、加载更早消息、或者多个标签页同时打开时。如果后端完全依赖前端传来的 messages,记忆系统就会退回到 UI 状态驱动。现在后端优先用 D1 里的历史,前端只提交最近 UI 消息和本轮请求必要字段,整个链路就更稳定。
我们还可以把这套 prompt 组装理解成一次上下文分拣。Agent 默认人设回答「你是谁、要以什么方式回答」;长期记忆回答「这个用户有哪些稳定偏好和边界」;会话摘要回答「更早之前聊到了什么」;最近消息回答「当前对话现场是什么」;本轮用户输入回答「这一次到底要处理什么」。这几个部分按顺序进入模型,回答才不容易飘。
到这里,发送前的准备工作就完成了。下一步就是调用上游 LLM,并把模型返回的内容一边流式送回前端,一边在结束后保存成 assistant 消息。那部分属于回复后的落库和记忆沉淀,后面再单独拆开。
这一篇讲的是模型调用前的准备。用户消息先落库,避免上游失败时丢失输入;随后后端从 D1 读取最近历史和长期记忆,再把 Agent 人设、记忆、摘要、聊天对象资料、历史消息和本轮输入组装成 prompt。这样一来,真正决定上下文的是服务端持久化数据,而不是前端当前那一份临时 messages。