本文基于 AgentScope 2.0.6 源码,示例项目为 myagent(AgentScope + FastAPI + Postgres + WebSocket)。
一、为什么一次对话要整载整存
LLM 无状态,对话的开场要整载全部状态,结束要整存全部状态,中间每轮推理只做增量。
这里有一个前提:LLM 是无状态的。模型不会记得你上一句说了什么,每次调用它,你都要把"它该知道的一切"重新喂给它。所以 Agent 必须自己扛起"记忆"这面大旗------而它扛的,就是每一次对话往返开始时被读进来、结束时被写回去的那份状态。
把一次对话往返画成一张图:
text
一条消息往返的状态流转(标出"动内存还是动 DB")
┌──────────────────────────────────────────────────────────────┐
│ 整载(开跑前) ──────────▶ 推理循环(增量) │
│ ┌──────────────────────────┐ ┌───────────────────────┐ │
│ │ get_session 读一整行 │ │ context += 用户消息 │ │
│ │ → 动 DB(SELECT) │ │ context += assistant │ │
│ │ model_validate 重建 │ │ context += 工具调用/结果 │ │
│ │ → 动内存 │ │ → 全动内存,不碰库 │ │
│ │ 记忆索引 + 工具 schema │ │ 事件 → message_bus │ │
│ │ → 动内存 │ │ → 不动库,只广播 │ │
│ └──────────────────────────┘ └───────────────────────┘ │
│ │ │
│ ▼ │
│ 落库(锁内整存) │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ upsert_message → 动 DB(逐条写) │ │
│ │ update_session_state → 动 DB(整体覆盖) │ │
│ │ 压缩摘要 → summary → 动内存,随整存一起落库 │ │
│ │ 记忆写回 → 动 DB / 文件 │ │
│ └─────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
三列各有一项职责:
- 整载 :会话历史 + 记忆 + 工具,从各处读进来,拼成喂给模型的第一份输入------这是阶段里唯一的数据库读。
- 推理循环 :每一轮思考、每一次工具调用、每一个返回结果,都被追加进上下文------全动内存,数据库不参与。
- 落库 :对话结束后,把变化后的状态整体写回数据库,等下一次往返再读------唯一的数据库写。
注意,上面的"一条往返"不是"一次会话":每一条消息都完整地走一遍"整载 → 推理 → 落库",一个会话是一连串往返的叠加。本文后面章节,都以"一条消息往返"为单位走查。
你可能已经发现:会话和记忆不是两个东西 ------Agent 读进来、写回去的就是记忆本身(短时记忆存于上下文,长时记忆存于存储)。在系列纵览《Agent 的解剖 -- Harness 到底在解什么》里我们说过,状态内核 = 短时记忆(上下文)+ 长时记忆(存储);本文就把这条线的实现细节拆开------一次对话到底读了什么、写了什么、写在哪。
读完之后你会明白:为什么 AgentScope 的会话要整载整存,而不是只存增量;为什么记忆是"事件驱动"而不是"定时任务";以及你的 myagent 距离"有记忆的 Agent"还差哪两行。
二、从 WebSocket 到数据库的八步
先不看源码,用最直观的方式把一条消息走一遍。以 myagent 里你的第 5 次对话为例,你问:"我上次让你查的天气怎么样了?"
一条消息从你的浏览器出发,到最终落库,走完 8 步:
text
你 (浏览器) myagent 服务 数据库
───────── ──────────── ────────
① 发消息 ──WS──▶ ② 收消息
│
▼
③ 整载:会话状态 ────────────────▶ SELECT sessions.state
│
▼
④ 装配:中间件 + 记忆 + 工具
│
┌───────▼─────────── 会话锁窗口(第 5、7 步)─────────┐
│ ⑤ 循环:推理 ↔ 行动(多轮) │
│ │ 每轮事件 ──message_bus──▶ 前端实时流 │
│ ▼ │
│ ⑥ 压缩(若超限) │
│ │ │
│ ▼ │
│ ⑦ 持久化 ────────────────────────▶ UPSERT messages │
│ │ UPDATE sessions.state
│ ▼ │
└───────┘ ⑧ 释放会话锁,等待下一条
这八步先记住整体,后面每章解开一步的源码。注意会话锁只包住 ⑤⑦(推理 + 持久化) :加锁在装配(④)之后、推理(⑤)之前(_chat.py 的 acquire_lock),解锁在持久化(⑦)完成之后。整载(③)和装配(④)在锁外 ------get_session 先于加锁执行,两者之间隔着多个 I/O 等待点。推理和落库被锁串行化,保证同一会话的"写"(改 state + 整存)同一时刻只有一个在跑(第六章展开锁的细节)。
第 1-2 步:入口
你发消息时,前端通过 WebSocket 发来 {type: "chat", session_id, content}(routers/ws.py)。服务端收到后,把内容包装成一条 AgentScope 的用户消息 Msg,交给 chat_service.run()。这是唯一一条路------所有对话都从这条 WS 通道进来。
第 3 步:整载会话状态
服务端拿到 session_id 后,第一步不是问模型,而是先把"你是谁、我们聊过什么"读出来 :从 sessions 表读整个会话状态(state 列里就装着完整对话流 context)。这就是整载。整载之后,Agent 手里才有"记忆"可凭。
注意读的是 sessions.state 一整行 ,不是 messages 表:messages 表逐条保存每轮消息(用户输入 + 最终回复),正常推理路径(Case A)只通过 upsert_message 写它、不读它,messages 表主要在 GET /messages 这类展示接口里被读。唯一的例外是续跑 (Case B):工具审批或外部结果回来后,按 reply_id 从 messages 表读回单条 reply 继续 append------但即便这里,读的也是一条,不是恢复整段上下文。所以"每次请求加载全部 messages"是不存在的------模型输入里的对话历史,全在 state.context 这一份里,详见第六章。
第 4 步:装配
光有历史还不够。Agent 还要知道"你能用哪些工具""记得哪些长期记忆"------这一层由中间件链完成:系统提示词、记忆索引、工具 schema,全部拼进喂给模型的第一份输入。
第 5 步:推理-行动循环
接下来进入 _reply_impl 的推理循环(ReAct,机制细节见第三篇《ReAct 循环》):推理 → 决定行动 → 执行工具 → 结果回流 → 再推理。每一轮产出的增量(用户消息、assistant 回复、工具调用与结果)都被追加进上下文,同时通过 message_bus 广播给前端,所以你看到的是流式输出。
第 6 步:压缩
对话越来越长,当上下文 token 数超过模型窗口的 80% 时,Agent 会触发压缩:把旧消息交给模型生成摘要,用摘要替换掉大部头历史。这是落库的一种特殊形态------写的是"压缩后的记忆"。
第 7 步:状态落库
回复结束后,Agent 在会话锁内 把变化后的状态整体写回数据库:新的消息逐条写入 messages 表,更新后的 AgentState 整体覆盖 sessions.state。写完才释放锁------保证下一条消息读到的永远是新鲜的。
第 8 步:等待
锁释放,会话回到 IDLE。你下一条消息,从第 1 步再来一遍。
后面六章要回答的三个问题
八步走完,你应该有三个待解的问题,也是本文剩下六章要回答的:
- 会话历史是从哪读的、怎么被装进 Agent 的? → 第三章
- 记忆和工具是怎么挤进系统提示词和上下文的? → 第四章
- 最后状态是怎么存回去的,为什么必须在锁内写? → 第六章
现在从第三章开始,逐章对照源码。
三、会话历史怎样被整载进内存
回到第二章第 3 步。用户的消息到达 chat_service.run() 之后,服务端做的第一件事是把整个会话状态从数据库读出来 。这一步的入口在 agentscope/app/_service/_chat.py 的 _run_impl 里:
python
session_record = await self._storage.get_session(
user_id, agent_id, session_id,
)
get_session 返回了什么
get_session 读的是 sessions 表的一整行(myagent 里是 storage/_postgres_storage.py 的 sessions_table):
text
sessions 表
┌──────────┬─────────┬───────────┬──────────┬────────┐
│ id │ user_id │ agent_id │ config │ state │
├──────────┼─────────┼───────────┼──────────┼────────┤
│ <sid> │ <uid> │ <aid> │ JSONB │ JSONB │
└──────────┴─────────┴───────────┴──────────┴────────┘
其中 state 这一列是整个 AgentState 的 JSON 序列化。AgentState 是 Agent 的全部内存状态(agentscope/state/_state.py):
python
class AgentState(BaseModel):
session_id: str # 会话标识
summary: str | list # 压缩摘要(compress 的产物)
context: list[Msg] # 完整对话流 ← 会话的核心
middle_context: dict # 中间件跨回复共享区
permission_context: ... # 权限上下文
tool_context: ... # 工具上下文(读文件缓存等)
tasks_context: ... # 任务上下文
也就是说,一次 get_session 就把 Agent 的全部记忆捞了出来 :context 是逐条消息的完整历史,summary 是历史被压缩后的摘要,middle_context 是中间件想跨轮保留的东西。
整载的完整链路是:
text
get_session() AgentState.model_validate() Agent(...)
──────────────── ─────────────────────── ──────────
SELECT sessions 整行 ──▶ state JSON → AgentState 对象 ──▶ 把 state 装进新 Agent
(动 DB) (动内存,重建) (开跑)
· 换节点/重启后,任何节点执行同样三步,重建出相同的 state.context
· 没有"常驻内存的会话对象"------每次请求都从 DB 重新整载
这条链路是"整载"三个动作的完整视图:DB 读 → 内存重建 → 注入 Agent。它解释了为什么请求落在哪个节点不重要(第三章末尾展开)。
整载,而不是只载增量
为什么是"整载"而不是只读"上一条之后新增的消息"?因为 AgentScope 的模型调用是无状态函数式的:每次推理都要把完整上下文喂给模型。模型看到的消息列表是:
text
system prompt(系统提示词)
+ summary(压缩摘要,若有)
+ context 全部消息(用户、助手、工具调用与结果)
所以 Agent 必须在开跑前把"它该知道的一切"都装进 state.context,推理循环里只做追加,不再回读数据库。整载保证了一次推理的输入自洽------这也是它和"按需懒加载"模型的根本区别。
state 注入 agent
读取之后,_run_impl 把这份状态注入即将运行的 Agent:
python
agent_state = session_record.state
agent_state.session_id = session_id # 用调用方传入的 id 覆盖
agent = self._agent_cls(
name=agent_record.data.name,
system_prompt=agent_record.data.system_prompt,
model=model,
toolkit=toolkit,
state=agent_state, # ← 整载的状态装进 Agent
...
)
注意 agent_state.session_id = session_id 这行:状态里的会话 id 以本次调用的 session_id 为准。这是 ReMe 等记忆中间件读 agent.state.session_id 的来源------它不配置在中间件上,而是每个会话实时取自 agent。
一次整载,全程不再碰数据库
从 agent = self._agent_cls(...) 之后,整个推理循环(第五章)都只操作内存里的 state.context,直到第六章的持久化才写回数据库。这意味着:数据库只在开头整载、结尾整存,中间不参与------"改"只发生在内存。
为什么换节点/重启后 context 不丢 。每次请求都是"全新 Agent + state 从数据库重建":_run_impl 每轮都重新 get_session → AgentState.model_validate → 装进新 Agent,没有一个"常驻内存的会话对象"。所以 A 节点和 B 节点读同一个 session_id,重建出的 state.context 一模一样------请求落在哪个节点根本不重要 。这正是整载整存换来的能力:服务节点是无状态的,水平扩展、滚动重启都不需要迁移任何内存状态。唯一的失忆窗口是"persist 之前崩溃":DB 停留在上一次成功写回的快照,本轮增量丢失,且框架没有内置补偿(asyncio.shield 只是尽量不让持久化被取消打断,不是恢复机制)。
四、记忆与工具怎样进入上下文
整载了会话历史,但 Agent 还缺两样东西:记忆 (它"知道"的长期事实)和工具 (它能"做"的能力)。这两样都在第三章说的 _run_impl 装配阶段和模型调用前准备好。这一章是记忆闭环的读端------读进上下文的记忆,旅程走到第七章会被写回(写端)。
装配阶段把三类东西拼进第一份输入:
text
装配:一次推理的输入怎么凑齐
┌────────────────────────────────────────────────────────┐
│ 系统提示词(on_system_prompt 管线) │
│ + 记忆索引 MEMORY.md(AgenticMemory) │
│ + 检索注入的相关片段(on_reasoning,若检索完成) │
│ + 压缩摘要 summary(若有) │
│ + 完整对话流 state.context │
│ + 工具 schema(toolkit.get_tool_schemas) │
└────────────────────────────────────────────────────────┘
(拼好的 dict 交给 _call_model)
下面逐个看这些零件从哪来。
中间件链装配
_run_impl 里先组装中间件列表(_chat.py):
python
middlewares: list = [
InboxMiddleware(self._message_bus), # 收件箱
StateChangeMiddleware(message_bus=..., ...), # 状态变更通知
ToolOffloadMiddleware(...), # 工具后台执行
]
if self._extra_agent_middlewares is not None:
middlewares.extend(
await self._extra_agent_middlewares(user_id, agent_id, session_id),
)
你的 myagent 可以在这里追加自己的中间件(比如记忆中间件)。Agent 构造时会自动检测每个中间件实现了哪些钩子(MiddlewareBase.is_implemented),只把这些钩子装进对应的链。中间件是"记忆"这种横切能力的挂载点。
记忆索引进系统提示词:on_system_prompt
每次模型调用前,Agent 会重新生成系统提示词(_agent.py 的 _get_system_prompt),它是一条管线 ------每个实现了 on_system_prompt 的中间件依次改写提示词。
以 AgenticMemoryMiddleware(文件系统记忆)为例(middleware/_longterm_memory/_agentic_memory/_middleware.py):
python
async def on_system_prompt(self, agent, current_prompt):
await self._ensure_layout() # 确保 Memory/ 目录存在
memory_md = await self._get_memory_md_content() # 读 MEMORY.md 索引
memory_md = self._truncate_if_needed(memory_md, memory_max_tokens)
instructions = self._parameters.memory_instructions.replace(
"{memory_dir}", self._get_memory_dir(),
)
return f"{current_prompt}\n\n{instructions}\n## MEMORY.md\n{memory_md}"
它的作用是:把记忆索引 (MEMORY.md,每条一行,指向具体记忆文件)追加到系统提示词末尾。这样模型每次推理都"看到"有哪些记忆可用,并在需要时用工具读取具体文件。注意它每轮重读------因为记忆可能在上一轮被模型新增了文件,索引要保持新鲜。
相关记忆片段:on_reply 起检索、on_reasoning 注入
索引给了模型"记忆目录",但真正"喂给模型的相关片段"来自异步检索。AgenticMemoryMiddleware 在 on_reply 里起一个后台任务,在 on_reasoning 里把结果注入:
python
# on_reply:回复开始时,缓存用户输入并起检索任务
if self._cached_input:
self._retrieval_task = asyncio.create_task(
self._retrieve_relevant_files(agent, self._cached_input),
)
# on_reasoning:每次推理前,若检索完成则注入
if self._retrieval_task is not None and self._retrieval_task.done():
retrieval_result = self._retrieval_task.result()
if retrieval_result:
agent.state.append_context(agent.name, [HintBlock(hint=retrieval_result)])
_retrieve_relevant_files 的实现也很讲究:先用 LLM 做相关性选择(结构化输出 _MemorySelection,从记忆文件清单里挑最多 5 个),再读取这些文件的内容、截断、拼成一个可注入的字符串。检索结果是 HintBlock,由 formatter 转换成消息块,随上下文一起发给模型。
关键点:检索与回复并发------回复开始就起 task,推理阶段轮询完成;若单次回复结束得比检索快,本次就跳过注入(best-effort)。这是 ReMe、mem0 中间件共用的模式。把这段时序画出来:
text
记忆读(检索)的时序
用户消息到达
│
▼
on_reply ──▶ 缓存输入,asyncio.create_task(检索) ← 起后台 task,不阻塞
│
▼
on_reasoning(每次推理前)──▶ 检索完成?─┬─ 是 ─▶ 注入 HintBlock 进 context
│ └─ 否 ─▶ 本次跳过(best-effort)
▼
...推理循环继续...
检索结果是 HintBlock,由 formatter 转成消息块,随上下文一起发给模型。
三种记忆中间件对比
AgentScope 2.0 在 middleware/_longterm_memory/ 下提供三种记忆中间件,差异在存储与写入主体:
| 维度 | AgenticMemory | Mem0 | ReMe |
|---|---|---|---|
| 存储 | Markdown 文件(Memory/ 目录) |
mem0 向量库(默认 Qdrant) | ReMe 文件卡片 + BM25/向量 |
| 写入主体 | LLM 主动(用 Write 工具写文件) | LLM 抽取(add) |
LLM 抽取(auto_memory) |
| 检索主体 | LLM 选文件(异步) | 向量相似度 | BM25/向量 + LLM |
| 依赖 | 无 | mem0 库 | reme-ai 包 |
| 控制模式 | 纯 agentic | static/agent/both | static/agent/both |
| 适合场景 | 简单、可人工编辑 | 生产级语义检索 | 官方一体化 |
三者的共同点是:都通过中间件钩子挂进会话的状态流转 ------on_system_prompt 注索引、on_reply/on_reasoning 注片段、on_reply 的收尾写回。实现细节差异不影响它们挂载的位置。
工具 schema 进 messages
最后,模型调用前的输入组装(_agent.py 的 _prepare_model_input):
python
messages = [
SystemMsg(name="system", content=await self._get_system_prompt()),
]
if self.state.summary:
messages.append(UserMsg(name="user", content=self.state.summary))
messages.extend(self.state.context)
tools = await self.toolkit.get_tool_schemas(
self.state.tool_context.activated_groups,
)
return {"messages": messages, "tools": tools}
至此,一次推理的输入凑齐了:系统提示词(含记忆索引)+ 压缩摘要 + 完整对话流(含注入的记忆片段)+ 工具 schema。输入到此凑齐,接着进入推理循环。
五、推理循环里 context 怎样增量增长
装配完成,进入推理循环(_agent.py 的 _reply_impl)。这一章看循环对内存的操作------但注意,这里的操作不是落库,而是追加内存里的 state.context。
用户消息入库:_handle_incoming_messages
循环开始前,Agent 先把用户输入追加进 context。_handle_incoming_messages(_agent.py)做两件事:校验 + 追加。
python
async def _handle_incoming_messages(self, msgs):
if msgs:
copied_msgs = deepcopy(msgs)
for msg in copied_msgs:
# 校验:只接受 user/assistant,不含 tool_call/tool_result/thinking
if (not isinstance(msg, Msg)
or msg.role == "system"
or msg.has_content_blocks(["tool_call", "tool_result", "thinking"])):
raise ValueError(...)
self.state.context.append(msg) # ← 追加进内存
校验是有意义的:用户的原始输入必须干净 ,不能带着工具调用块进来(那是 agent 内部产物)。校验通过才 append 进 state.context------这是"写"的第一步。
assistant 消息:reply_id 即消息 id
推理产生 assistant 消息时,_save_to_context(_agent.py)用当前 reply_id 作为消息 id:
python
def _save_to_context(self, blocks, usage=None):
...
msg = AssistantMsg(
id=self.state.reply_id, # 一轮回复一个 id
name=self.name,
content=blocks,
)
self.state.context.append(msg)
一个 reply_id 对应一轮回复------流式事件里吐出的每个文本块、工具调用块都归到这条 assistant 消息下,直到回复结束。这样 context 里"一条 assistant 消息 = 一次完整回复",方便后续压缩、持久化按"轮"为单位处理。
工具回流:tool_call 与 tool_result
循环的 Acting 阶段,工具调用与结果也写进 context。工具被调用时,tool_call 块追加到 context;工具执行完,tool_result 块追加在对应的 tool_call 之后。这两块在 context 里是配对的 (同一 id),模型据此知道"这个调用拿到了这个结果"。
事件流:context 之外的另一路输出
除了写 context,循环还通过 message_bus 把每个事件广播出去 (_chat.py 里 publish_session_event):
text
REPLY_START → 前端显示"开始回复"
TEXT_BLOCK_DELTA → 前端逐帧显示文本
TOOL_CALL_START → 前端显示"正在调用工具 X"
TOOL_RESULT_END → 前端显示"工具返回了"
REPLY_END → 前端显示"回复结束"
这条"写"不进数据库,而是给前端看------myagent 的 routers/ws.py 订阅 MessageBusKeys.session_events(session_id),把事件转成前端协议推给浏览器。它和"写 context"是并行的两条流:一条喂模型,一条喂用户。
一轮循环下来 context 里多了什么
一轮循环下来,state.context 里新增了:
text
context(内存)的状态演进
[..., user_新消息] ← _handle_incoming_messages 追加
↓
[..., user_新消息, assistant_新回复] ← _save_to_context(reply_id 归组)
↓
[..., user_新消息, assistant_新回复,
tool_call, tool_result, ...] ← 工具回流(同 id 配对)
这些全部在内存中增量完成,数据库未参与。直到上下文超长触发压缩(第六章),或回复结束触发持久化(第六章后半)。
六、压缩与持久化:状态怎样落库
循环收尾有两件事:压缩 (context 太长时的降级)和持久化(回复结束的整体落库)。这一章一起讲,因为它们都发生在收尾阶段。
压缩:0.8 触发,旧消息变摘要
每次进入推理前,Agent 会检查上下文是否超长(compress_context → _compress_context_impl):
python
cfg = self.context_config
kwargs = await self._prepare_model_input()
estimated_tokens = await self.model.count_tokens(**kwargs)
threshold = cfg.trigger_ratio * self.model.context_size # 默认 0.8 * ctx
if estimated_tokens < threshold:
return # 没超限,跳过
默认 trigger_ratio = 0.8:上下文用到模型窗口的 80% 才压。超限后的内部过程:
text
压缩内部:token 预算扫描 + 边界劈半
context(内存,从尾部往前扫)
┌────────────────────────────────────────────────────────┐
│ [旧消息₁] [旧消息₂] ... [边界消息] [近消息₃] [近消息₂] [近消息₁] │
│ └──── 被压掉(进摘要/offload)──┘ └──── 保留 ────┘ │
│ ▲ 边界消息:按内容块劈成两半 │
│ │ · 算法避开拆散 tool_call/tool_result 配对 │
│ │ · 保留预算 = reserve_ratio × context_size │
│ │ = 从尾部往前扫,token 总和不超过预算 │
└──────────┴─────────────────────────────────────────────┘
summary(滚动折叠)
[旧摘要] + [被压掉的旧消息] ──喂模型──▶ 新摘要(整体覆盖旧摘要)
- 保留多少 :
reserve_ratio = 0.1------从 context 尾部往前扫,保留总和不超过窗口 10% 的 token (是预算不是条数:一条很长的工具结果可能占满整个预算)。边界那条消息还可能按内容块劈成两半,且算法会避开拆散tool_call/tool_result配对。 - 摘要怎么生成 :旧消息 + 系统提示词 + 压缩提示词,交给模型结构化输出(
SummarySchema),再套进summary_template模板。 - 旧消息去哪了 :如果配了
offloader(如 myagent 的 workspace),压缩掉的旧消息落盘到文件,摘要尾部附一行 reminder 指向该文件;否则直接丢弃。
压缩改了什么、不碰什么 :压缩只改内存里 state 的两份数据------
context:删旧留新 ------被压掉的旧消息从state.context移除,只保留最近 ~10% token 的消息(msgs_to_reserve)。summary:写入新摘要 ------模型生成的摘要套上summary_template,存进state.summary。
落库发生在回复结束的持久化:_persist 里 update_session_state 把整个 state(含压缩后的 context 与 summary)整存覆盖 回 sessions.state。messages 表不受影响------它逐条记录每轮消息(用户输入 + 最终回复),压缩不删不改。
关于"直接丢弃"再澄清一句:丢弃的是工作记忆里的旧消息,不是删除数据库记录 。配了 offloader(如 myagent 的 workspace)时旧消息落盘到文件,摘要尾部附一行 reminder 指向该文件;否则它们只是从 state.context 里被替换掉,messages 表的历史依然完整。
压缩本质是会话历史 → 摘要的降级写 :远历史从"逐条消息"变成"一段摘要",存进 state.summary,随每轮推理一起送进模型。这是状态流转里最特殊的一次写------它改写的是推理输入的原材料本身。
长会话:反复压缩,摘要滚动折叠 。一次压缩只解决当下超限,长会话里 context 会再次长过 80%,所以压缩会反复触发 。每次压缩,state.summary 都是整体覆盖 而不是累积------因为压缩输入里带着当前 state.summary(先放摘要、再放旧消息一起喂给模型),模型把"旧摘要 + 旧消息"浓缩成新摘要,旧的被折叠进去。所以长会话的摘要是一条不断折叠的滚动摘要,不是多条记录。
配了 offloader 时则相反:每次压缩丢弃的旧消息都 append 到同一个 sessions/<session_id>/context.jsonl (一行一条消息),多次压缩会让这个文件累积多批。messages 表则始终逐条保留全部历史,与压缩次数无关。
把"读"串起来:模型每次推理的输入是 system + summary + 当前 state.context (第四章的 _prepare_model_input 直接展开)。压缩截断 state.context,之后推理循环又从截断处继续 append------所以 state.context 是一条被反复截断的滑动窗口链 ,messages 表全程不参与模型输入。
压缩的"标尺"是 context_size,它来自模型类,不是模型 API 。触发阈值 = trigger_ratio(0.8) × context_size(_agent.py),保留预算 = reserve_ratio(0.1) × context_size。而 context_size 是 agentscope 模型类的硬编码默认值 ,不同入口拿到的不一样:openai_credential(OpenAI 兼容,你的 DeepSeek 就是这么接的)默认 128,000 ;DeepSeek 原生模型类默认 65,536。这是配置问题不是 bug------但它和模型真实窗口的错位会造成过早压缩:
text
压缩标尺 vs 模型真实窗口(示意)
0 65K 102K 128K 1M
│ │ │ │ │
│ DeepSeek 类触发线 ~51K │ │ │
│ (0.8×64K) │ │ │ │
│ ├───────┤ │ │
│ OpenAI 入口触发线 ~102K │ │ │
│ (0.8×128K) │ │ │ │
│ │ │ │
│ 系统在这就压缩 ←───────────┘ │ │
│ 模型实际能扛 1M(DeepSeek-V4, 2026-08 文档)──────────────────────▶│
│ 早压 = 长文档/长会话的记忆被白白提前折叠 │ │
└────────────────────────────────────────────────────────────────┘
比如 DeepSeek-V4 官方上下文是 1M(2026-08 文档),系统却在 ~102K(0.8 × 128K)就触发压缩,模型明明还能扛近 10 倍。要让压缩对齐真实窗口,需在建模型时显式传 context_size。
持久化:锁内整存
回复结束后,真正的数据库写发生在 _persist(_chat.py 的 finally 块):
python
async def _persist() -> None:
if reply_msg is not None:
await self._storage.upsert_message(user_id, session_id, reply_msg)
await self._storage.update_session_state(
user_id=user_id, agent_id=agent_id,
session_id=session_id, state=agent.state,
)
await self._message_bus.log_trim(events_key)
persist_task = asyncio.create_task(_persist())
try:
await asyncio.shield(persist_task)
except asyncio.CancelledError:
await persist_task
raise
两个关键设计:
- 整体覆盖,不是增量 :
update_session_state把整个agent.state序列化后整体写回sessions.state列------不是只写新增的消息。这与开场的"整载"对称,构成完整的整载-整存闭环。 - 锁内 + shield 防取消 :持久化必须在会话锁释放之前 完成(否则别的 worker 可能拿到旧状态)。
asyncio.shield保护它不被外层CancelledError中断。
把这点说透:整存必须在释放会话锁之前完成,否则并发读可能拿到旧状态。AgentScope 用"锁内整存"保证了会话状态的一致性------它只承诺"下一位读者读到的是最新状态",不含回滚、原子性等承诺。
锁加在哪一行 。锁不在 _persist 里,而在 _run_impl 的装配之后------async with acquire_lock(session_lock)(_chat.py:798)包住的是从推理到持久化的那一段 :推理循环改 state.context、_persist 整存,全在锁内。所以"锁内整存"的准确含义是位置关系------_persist 在 async with 块的最末尾、释放锁之前 执行。注意锁不包整载 :get_session(L545)在加锁之前,锁串行化的是"改 + 写"(推理 + 整存),不是"读-改-写"整个周期------整载和装配在锁外。
广播与持久化:前端先看到,真相后落库 。锁内每个事件走三步:先同步 append 进 reply_msg(内存里的本轮回复,不可中断),再 publish_session_event 广播给前端 (SSE/WS 流式展示),推理结束进 finally 块的 _persist 才落库 。前端先看到、持久化后落库,为什么不会乱?三层保护:① 锁释放必须等 _persist 完成------persist_task = create_task(_persist()) 后用 asyncio.shield 等它,async with 的退出被挡住,下一位读者拿不到旧 state;② 即使被 CancelledError 中断,也先 await persist_task 再 raise------被取消也要先落库再退锁;③ 前端根本不依赖持久化成功------广播是"正在发生"的实时通道,messages 表 / sessions.state 才是真相,前端刷新后以 DB 为准。所以有两层一致性要分清:锁管 DB 一致性 (同一会话的"改 + 写"串行 + 整存覆盖),不管前端实时同步(广播可能超前于持久化,靠最终一致 + 刷新纠正)。
把锁、广播、持久化的位置关系画成一张时序图:
text
锁内时序:整载 → 装配 → 加锁 → 推理 → 广播 → 整存 → 释放
浏览器 ChatService MessageBus 数据库
───── ────────────────── ────────── ──────
│ WS chat ───▶│ │ │
│ │ get_session ─────────────────────────────────▶│ 整载(锁外)
│ │ 装配:中间件/工具/模型 │
│ │ acquire_lock ──────────────▶│ SET NX EX │ ← 加锁
│ │◀─────────────── 锁获取 ──────│ │
│ │ 推理循环... │ │
│◀── 事件流 ────┤── publish_session_event ───▶│ → 前端 SSE/WS │
│ │ 推理结束 │ │
│ │ _persist: │ │
│ │ ├ upsert_message ────────────────────────────▶│ 写 messages
│ │ └ update_session_state ───────────────────────▶│ 整存 state
│ │ release_lock ──────────────▶│ GET校验→DEL │ ← 释放
│ │◀────────────── 锁释放 ────│ │
(整存必须发生在 release_lock 之前,这就是"锁内整存")
时序图的两个要点:加锁在整载之后 (整载和装配在锁外,锁包住的是"推理 + 整存"这一写侧),整存在释放锁之前(下一位读者必然读到最新状态)。
锁为什么必须是分布式的 。锁内整存不是单机洁癖,而是多节点一致性的全部所在:sessions.state 是唯一真相,谁服务这条消息,谁就整载读出、内存改、整存写回;节点之间没有主动同步,串行完全靠锁。同一个会话同一时刻只允许一个节点持有锁,否则两个节点各自改 state、各自整存,后写覆盖先写,会话状态就乱了。
需要精确一点:锁串行化的是写侧 (推理改 state.context + 整存写回),整载在锁外 ------get_session 先于加锁执行。如果同一会话的两个用户消息真正并发(第二个在第一个整存完成前完成整载),后写仍可能覆盖先写。框架没有版本号/乐观锁/加锁后重读来兜底,它靠三个现实假设:① 锁的主要职责是串行化"唤醒/工具回流/调度"这类与用户消息交错的后台 run(这才是真实的同会话并发源),以及用户消息本身;② 路由层通常按 session 粘性分发请求;③ 双用户消息并发到同一 session 被视为罕见、可接受丢失。这也解释了为什么"锁内整存"仍成立------它保证的是"下一位读者读到最新状态",针对的是上述真实并发源;而极端双用户并发属于框架主动放弃的边界。
锁从哪来?看 message_bus:RedisMessageBus 用 SET key NX EX + 心跳续期实现跨节点分布式锁 ;InMemoryMessageBus 只有进程内锁。myagent 当前用 InMemoryMessageBus(server.py),所以单进程部署才安全 ------要上多节点,必须换成 RedisMessageBus。
两条写库路径
| 写什么 | 写哪 | 方式 |
|---|---|---|
| 每条消息 | messages 表(逐条) |
upsert_message |
| 整个状态 | sessions.state(整体覆盖) |
update_session_state |
上面是"写什么、写哪"的两条路径。站在"同一批消息存在几份"的角度看,其实是三份同源副本------同一批消息(用户输入 + 回复)分布在三个地方,各自职责不同、生命周期不同、消费者不同:
text
三份同源副本:同一批消息的三个投影
┌────────────────────────────────────────────────────────┐
│ 用户输入 / assistant 回复 / 工具调用与结果 │
└───────┬───────────────┬──────────────┬─────────────────┘
│ │ │
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌──────────────────┐
│ state.context│ │ messages 表 │ │ context.jsonl │
│ 工作集 │ │ 审计日志 │ │ 冷档案 │
│ 喂模型 │ │ 给人看 │ │ 给 agent 回看 │
│ 会压缩截断 │ │ 永久不可变 │ │ 只增 │
│ 读:推理 │ │ 读:GET/messages│ │ 读:按需检索 │
│ 写:推理循环 │ │ 写:_persist │ │ 写:压缩时 append │
└─────────────┘ └─────────────┘ └──────────────────┘
| 副本 | 内容 | 生命周期 | 消费者 | 会不会变 |
|---|---|---|---|---|
sessions.state.context |
当前窗口 + summary |
有界(超 80% 压缩) | 模型推理 | 会------压缩截断 |
messages 表 |
全部消息逐条 | 永久、只增 | 前端展示 | 不变 |
context.jsonl |
压缩掉的旧消息 | 永久、append | agent 按需回看 | 只增 |
为什么三份都不能省?两个关键点:
context不可从messages表重建 。压缩是不可逆降级 :旧消息被模型浓缩进摘要,反推不出"当时压缩成了什么"(结果取决于当时的系统提示词、模型、状态,非确定性)。所以context + summary必须原样持久化,不能靠全量历史推导。context.jsonl是给 agent 的,不是给前端的 。正常推理路径不读messages表(见第三章,唯一的例外是续跑时按reply_id读回单条 reply),压缩掉的旧消息若不落盘,agent 自己就看不到历史了------context.jsonl就是给 agent 按需检索的冷档案,摘要尾部的 reminder 指向它。
一句话:三份是同一信息在不同抽象层的独立投影------工作集(喂模型,会压缩)、审计日志(给人看,永久)、冷档案(给 agent 回看,只增)。它们同源,但生命周期和消费者各不相同,任何一份都无法被另外两份精确重建。
七、记忆怎样进入长期存储,以及它为什么不是后台定时任务
最后一种"写"是记忆写回 ------把对话里的长期事实沉淀到记忆库(ReMe 卡片 / mem0 向量库 / Markdown 文件)。这是记忆闭环的写端 ,对应第四章的读端:第四章把相关旧记忆读进上下文,本章把这次对话产生的新记忆写回长期存储。这里有一个最常见的误解,必须澄清:长期记忆不是后台定时任务,而是事件驱动的钩子。
记忆写回:挂在 on_reply 的收尾
以 ReMe 中间件为例(middleware/_longterm_memory/_reme/_middleware.py),记忆写回在 on_reply 的 finally 块里:
python
async def on_reply(self, agent, input_kwargs, next_handler):
...
try:
async for item in next_handler(**input_kwargs):
yield item
finally:
# 本轮新增的消息(按 message id 差集计算)
increment = [m for m in agent.state.context
if isinstance(m, Msg) and m.id not in pre_ids ...]
if query_text and any(m.role == "assistant" and m.get_text_content()
for m in increment):
await self._write_back(increment, session_id) # ← 记忆写回
_write_back 调用 ReMe 的 auto_memory job------LLM 从这段对话里抽取值得记住的事实 ,按 session_id 写入卡片库。
触发时机是"有新对话 "这个事件,而不是某个时间点。这是与"定时任务"的根本区别:
| 触发模型 | 触发源 | AgentScope 对应物 |
|---|---|---|
| 时间驱动 | 定时器(cron) | SchedulerManager(APScheduler,管 schedule) |
| 事件驱动 | 有新对话 / 有回复结束 | on_reply / on_reasoning 钩子 |
| 后台常驻 | 独立 worker 进程 | IndexWorker(管知识库文档索引) |
AgentScope 里确实有定时调度(SchedulerManager 用 APScheduler),也有后台 worker(IndexWorker 用进程池解析文档),但它们都不是长期记忆 ------长期记忆属于"事件驱动 + 异步并发":每条对话触发一次检索、回复结束触发一次写回,asyncio.create_task 让它与主流程并发,而不是按固定周期跑。
python
# on_reply:起检索 task(与回复并发,非定时)
if self._cached_input:
self._retrieval_task = asyncio.create_task(
self._retrieve_relevant_files(agent, self._cached_input),
)
写回与检索的时序
一个完整记忆回合,事件顺序是:
text
记忆闭环:读端(第四章)→ 推理 → 写端(本章)
┌────────────────────────────────────────────────────┐
│ on_reply ────▶ 缓存用户输入,起异步检索 task │ ← 读端
│ (读:本次对话相关的旧记忆) │
│ ↓ │
│ on_reasoning ──▶ 检索完成则注入 HintBlock │
│ (供本次推理用) │
│ ↓ │
│ ...推理循环... │
│ ↓ │
│ on_reply finally ──▶ 计算本轮增量 │ ← 写端
│ (按 message id 差集) │
│ ↓ │
│ _write_back ──▶ auto_memory 抽取并写入卡片库 │
│ (写:本次对话产生的新记忆) │
│ ↓ │
│ 下一次对话 on_reply ──▶ 检索命中这些新记忆 │ ← 闭环
└────────────────────────────────────────────────────┘
读记忆(检索)和写记忆(写回)都绑在这次会话的事件上:读了这次对话相关的旧记忆,写了这次对话产生的新记忆。下次对话开头,检索就能命中这些新记忆------这就是"跨会话记得"的完整闭环。
写回是"尽力而为"的
注意写回通常在 finally 里且失败只记日志(ReMe 的 _write_back 里 logger.warning 而不抛),检索也 best-effort(单次回复可能先于检索结束而错过注入)。记忆是"尽力而为"的增强,不是"必须保证"的主流程------这保证了记忆故障不会拖垮对话。这是把记忆放在中间件(而非 Agent 核心)的必然结果。
八、myagent 的完整闭环:缺了记忆的那两行
把前面七章的机制,映射到你的 myagent 上,你会发现一个清晰的结论:myagent 的状态流转闭环已经完整,但它只覆盖了"会话",没有覆盖"记忆"。
myagent 现在的闭环
routers/ws.py 收到消息 → chat_service.run() → 整载会话 → 推理 → 持久化。对照全文:
text
myagent 状态流转闭环(现状,对照"一条消息的旅程")
┌────────────────────────────────────────────────────────────┐
│ 整载 get_session → AgentState [有] ← 第三章 │
│ 推理循环 推理循环 → context 增量 [有] ← 第五章 │
│ 落库 upsert_message + update_session_state [有] ← 第六章 │
│ 记忆读 记忆检索注入上下文 [无] ← 第四章 │
│ 记忆写 记忆写回(auto_memory 等) [无] ← 第七章 │
└────────────────────────────────────────────────────────────┘
myagent 的 sessions 表 + messages 表实现了会话状态的整载与落库(storage/_postgres_storage.py),但没有任何记忆中间件挂进 chat_service.run() 的中间件链------所以每个会话都是"从零开始"的:新会话不记得用户偏好、不记得之前会话的决策。
加记忆的最小改造点
按 AgentScope 的状态流转框架,myagent 加记忆只需要在两个位置各加一行:
text
整载时加一行:检索
在 _prepare_model_input(或中间件 on_system_prompt)里,
把相关记忆片段注入系统提示词 / 上下文。
落库时加一行:写回
在 _persist(或中间件 on_reply 收尾)里,
把本轮对话增量交给记忆抽取(LLM 抽取或向量化入库)。
具体到 AgentScope 生态,最省力的方式是直接挂一个现成中间件 :在 chat_service.run() 的 middlewares 列表里加 Mem0Middleware 或 ReMeMiddleware,状态流转自动补齐------检索进 on_reasoning、写回进 on_reply,无需改任何会话代码。这就是"记忆做成中间件"的收益:会话的状态流转结构不动,记忆作为横切能力插入。
压缩产物要不要沉淀进记忆
文章第六章讲过压缩:旧消息被模型压成 summary,存进 state.summary,随每轮推理送进模型。但这里有个设计取舍------压缩产物只服务"当前会话的后续轮次",不会跨会话共享。
你可以在 myagent 里做一个实验性的改造:把压缩产生的 summary(或 offload 落盘的旧消息)额外写入记忆库,让它在未来会话的检索里也能命中。这样:
- 当前会话:靠
summary维持长对话连贯(AgentScope 默认行为) - 未来会话:靠记忆库检索到"上次聊到哪、结论是什么"(记忆的跨会话价值)
这就是"压缩即记忆"的思路------把"整载-推理-落库"里的降级写,变成"长期记忆"的输入。它不是 AgentScope 内置行为,但你的 myagent 的状态流转结构完全能承载它:落库时多一次记忆写入,整载时多一次记忆读取。
会话与记忆是一体两面
回到开篇的核心结论:LLM 无状态,一次对话的开场要整载全部状态,结束要整存全部状态。看完七章源码,你手里现在有完整的地图:从第一章的总览图到第六章的锁时序图,你已经看过"一条消息的旅程"在每个阶段的样子------整载(第三章)、装配(第四章)、推理增长(第五章)、压缩与持久化(第六章)、记忆写回(第七章),以及 myagent 缺的那两行(本章)。
会话状态、记忆、压缩三者共同构成 Agent 的"状态"内核,各司其职:会话状态承载每次推理的上下文,记忆负责跨会话沉淀,压缩负责在上下文超长时降级。你的 myagent 已经有一半(会话状态流转),补上记忆读写的两行,它就能跨会话记得你。这也正是 Agent 从"问答机"走向"能长期协作的助手"的那一步。