创建时间: 2026-06-09最后更新: 2026-06-09

聊天发送流程

发送聊天消息时,流程比之前多了几步。

在没有记忆系统之前,聊天发送链路比较直接:前端把当前 UI 里的 messages 发给后端,后端把这些消息转成模型输入,然后把模型输出流式返回给前端。这个流程能跑起来,但它有一个明显限制:上下文主要来自前端当前状态。

接入记忆系统以后,发送链路要发生变化。后端不能只相信前端传来的消息列表,而是要把服务端已经保存的历史消息、会话摘要和长期记忆一起纳入考虑。也就是说,前端仍然负责提交本轮用户输入和必要的会话信息,但真正参与 prompt 组装的上下文,应该以服务端持久化数据为准。

这样处理以后,聊天状态会稳定很多。用户刷新页面以后,前端 messages 会重新初始化;用户切换 Agent 后,前端状态也会变化;甚至用户打开多个标签页时,不同页面里的 UI 状态都可能不一致。服务端 D1 里的历史消息才是统一来源。发送时回到 D1 读取上下文,能让聊天行为不依赖某一次页面状态。

index.txt
01
用户发送消息
02
03
API 校验 token
04
05
找到用户拥有的 Agent
06
07
获取默认 conversation
08
09
写入用户消息
10
11
读取最近历史消息
12
13
读取长期记忆
14
15
组装 LLM prompt
16
17
调用上游 LLM
18
19
流式返回给前端
20
21
流结束后写入 assistant 消息
22
23
更新 summary、message_count、last_message_at_ms
24
25
抽取长期记忆

这张流程图里,本文先讲到 prompt 组装为止。流式返回、assistant 消息落库、摘要更新和长期记忆抽取属于模型回复后的处理,放在下一段链路里看会更清楚。

先写入用户消息

用户消息会在调用 LLM 前先写入 D1:

我们需要先讨论写入顺序。直觉上,我们可能会等 LLM 成功返回以后,再把用户消息和 assistant 回复一起保存。这样写起来似乎更整齐,但会带来一个体验问题:如果上游 LLM 调用失败,用户刚刚输入的内容也会丢掉。用户明明已经发送了一条消息,刷新后却看不到它,这会让聊天历史不可信。

因此这里选择先保存用户消息。只要用户请求已经通过鉴权,并且能确认当前 Agent 和 conversation,就先把这条用户消息写入 agent_conversation_messages。后续 LLM 成功时,再补 assistant 消息;如果 LLM 失败,用户消息仍然留在历史里,页面列表也可以显示用户刚刚发出的内容。

index.ts
01
if (agentId && conversationId && latestUserText) {
02
sourceUserMessageId = crypto.randomUUID()
03
const userMessageNowMs = Date.now()
04
05
await insertAgentConversationMessage({
06
db,
07
id: sourceUserMessageId,
08
conversationId,
09
userId: claims.sub,
10
agentId,
11
role: 'user',
12
content: latestUserText,
13
status: 'completed',
14
nowMs: userMessageNowMs,
15
})
16
17
await updateAgentConversationAfterMessage({
18
db,
19
userId: claims.sub,
20
agentId,
21
conversationId,
22
summary: ownedConversation?.conversation.summary ?? null,
23
messageCount: (ownedConversation?.conversation.messageCount ?? 0) + 1,
24
lastMessageAtMs: userMessageNowMs,
25
nowMs: userMessageNowMs,
26
})
27
}

如果上游 LLM 调用失败,用户刚刚发出的消息也不会丢。它仍然会成为历史消息的一部分。

这里还同步更新了 conversation 的 messageCountlastMessageAtMs。这一步不是为了 prompt,而是为了会话状态。用户消息一旦写入,会话就已经发生变化。即使 assistant 还没有返回,首页列表也应该知道这个 Agent 刚刚有新互动。lastMessageAtMs 会影响排序,messageCount 会影响后续会话统计和摘要判断。

sourceUserMessageId 也在这里保存下来。它后面会用于长期记忆的来源关联。比如从本轮用户消息里抽取出一条偏好,就可以把记忆的 sourceMessageId 指向这条用户消息。这样记忆库页面以后就能追溯:这条记忆是从哪条对话里来的。

当然,并不是所有边缘路径都一定能写入用户消息。比如缺少 agentIdconversationId 时,代码会走兼容逻辑。正因为存在这些边缘情况,后面更新消息数时才不能简单固定 +2,而要根据是否真的写入过用户消息来决定。

读取历史和长期记忆

调用 LLM 之前,后端会读取两类上下文。

用户消息落库以后,就要准备模型输入。这里不直接使用前端传来的全部 messages,而是从服务端读取最近历史和长期记忆。前端传来的消息仍然有价值,它能提供当前 UI 的最新输入,也能作为 contracts 的必要字段;但服务端上下文应该由服务端自己确认。

最近聊天消息解决的是当前对话连贯性。用户前几轮说了什么,assistant 刚刚怎么回答,当前问题接着哪句话而来,这些都需要最近消息来支撑。没有最近消息,模型很容易把本轮输入当成孤立问题来回答。

最近聊天消息:

index.ts
1
const storedRecentMessages = agentId && conversationId
2
? await listAgentConversationMessages({
3
db,
4
userId: claims.sub,
5
agentId,
6
conversationId,
7
limit: recentMessageLimit,
8
})
9
: []

长期记忆:

长期记忆解决的是跨会话稳定偏好。它不一定在最近几条消息里出现,但会影响回答方式。比如用户曾经表达过不喜欢过度分析,或者希望回复更自然直接,这类记忆就应该在每次聊天时被优先尊重。

index.ts
1
const activeMemories = agentId
2
? await listActiveAgentMemories({
3
db,
4
userId: claims.sub,
5
agentId,
6
limit: memoryInjectionLimit,
7
})
8
: []

长期记忆按重要度和更新时间排序:

index.ts
1
.orderBy(sql`${agentMemories.importance} desc, ${agentMemories.updatedAtMs} desc`)

这样排序以后,重要记忆会优先进入 prompt。

这里按 importance descupdatedAtMs desc 排序,是第一版没有向量检索时的简单策略。重要度高的记忆优先进入 prompt;重要度相同,则更新更近的记忆优先。这个方案不一定永远最准确,但在记忆数量不多时非常直接,也容易解释给用户。

我们还要注意一个边界:长期记忆不是越多越好。每条记忆都会占用 prompt 空间,如果无节制地注入,反而会挤掉最近消息和用户本轮输入。第一版用 memoryInjectionLimit 控制数量,就是为了让长期记忆参与回答,但不主导整个上下文。

组装 prompt

系统 prompt 里会注入 Agent 默认人设、长期记忆和会话摘要:

prompt 组装决定模型最终能看到什么,也决定 Agent 的回答风格是否稳定。这里采用的顺序是先系统级约束,再聊天对象资料,再历史消息,最后是本轮用户输入。

系统 prompt 的第一层是 Agent 默认人设。它描述这个 Agent 应该如何回答、应该保持什么语气、应该避免哪些风险。长期记忆和会话摘要也放在系统消息里,是为了让模型把它们当作更高优先级的背景信息,而不是普通用户输入。

index.ts
01
const messages: ChatCompletionMessage[] = [
02
{
03
role: 'system',
04
content: [
05
agentPrompt?.defaultPrompt || '你是 AI Agent Web 控制台里的聊天陪伴助手。',
06
'请基于当前聊天对象、关系氛围和用户意图,用简洁、自然的中文回答用户。',
07
'如果用户要求起草回复,请直接给出可发送的聊天内容,避免正式公文格式和职场汇报语气。',
08
'你的建议应尊重双方边界,避免操控式话术、制造焦虑或诱导过度解读。',
09
activeMemories.length > 0
10
? [
11
'以下是用户与该 Agent 的长期记忆,请优先尊重:',
12
...activeMemories.map((memory) => `- [${memory.type} / 重要度 ${memory.importance}] ${memory.content}`),
13
].join('\n')
14
: '',
15
ownedConversation?.conversation.summary
16
? `此前对话摘要:${ownedConversation.conversation.summary}`
17
: '',
18
].join('\n'),
19
},
20
]

然后追加当前聊天对象资料:

当前聊天对象资料单独作为一条 user message 追加进去,是为了让模型知道这次对话面对的是哪个 Agent,以及这个 Agent 的关系阶段、主题、节奏、备注等信息。它不是长期记忆,也不是聊天历史,而是当前聊天对象的静态上下文。

index.ts
01
messages.push({
02
role: 'user',
03
content: [
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,模型就能理解对话轮次。

index.ts
01
for (const message of promptHistory) {
02
const text = normalizeStoredMessage(message.content)
03
04
if (!text) {
05
continue
06
}
07
08
messages.push({ role: message.role, content: text })
09
}
10
11
if (storedRecentMessages.length > 0 && latestUserText) {
12
messages.push({ role: 'user', content: latestUserText })
13
}

这里有一个细节:前端虽然也会传 messages,但后端优先使用 D1 中保存的历史。这样用户刷新、切换 Agent、加载历史后,真正决定上下文的是服务端持久化数据,而不是前端临时状态。

这里需要再强调一次:前端消息和服务端历史可能短暂不一致,尤其在刚发送、刚刷新、加载更早消息、或者多个标签页同时打开时。如果后端完全依赖前端传来的 messages,记忆系统就会退回到 UI 状态驱动。现在后端优先用 D1 里的历史,前端只提交最近 UI 消息和本轮请求必要字段,整个链路就更稳定。

我们还可以把这套 prompt 组装理解成一次上下文分拣。Agent 默认人设回答「你是谁、要以什么方式回答」;长期记忆回答「这个用户有哪些稳定偏好和边界」;会话摘要回答「更早之前聊到了什么」;最近消息回答「当前对话现场是什么」;本轮用户输入回答「这一次到底要处理什么」。这几个部分按顺序进入模型,回答才不容易飘。

到这里,发送前的准备工作就完成了。下一步就是调用上游 LLM,并把模型返回的内容一边流式送回前端,一边在结束后保存成 assistant 消息。那部分属于回复后的落库和记忆沉淀,后面再单独拆开。


总结

这一篇讲的是模型调用前的准备。用户消息先落库,避免上游失败时丢失输入;随后后端从 D1 读取最近历史和长期记忆,再把 Agent 人设、记忆、摘要、聊天对象资料、历史消息和本轮输入组装成 prompt。这样一来,真正决定上下文的是服务端持久化数据,而不是前端当前那一份临时 messages。