会话状态的生命周期 -- 以一条消息为例

本文基于 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.pyacquire_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_idmessages 表读回单条 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 步再来一遍。

后面六章要回答的三个问题

八步走完,你应该有三个待解的问题,也是本文剩下六章要回答的:

  1. 会话历史是从哪读的、怎么被装进 Agent 的? → 第三章
  2. 记忆和工具是怎么挤进系统提示词和上下文的? → 第四章
  3. 最后状态是怎么存回去的,为什么必须在锁内写? → 第六章

现在从第三章开始,逐章对照源码。

三、会话历史怎样被整载进内存

回到第二章第 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.pysessions_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_sessionAgentState.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 注入

索引给了模型"记忆目录",但真正"喂给模型的相关片段"来自异步检索。AgenticMemoryMiddlewareon_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 内部产物)。校验通过才 appendstate.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.pypublish_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

落库发生在回复结束的持久化:_persistupdate_session_state 把整个 state(含压缩后的 contextsummary整存覆盖sessions.statemessages 表不受影响------它逐条记录每轮消息(用户输入 + 最终回复),压缩不删不改

关于"直接丢弃"再澄清一句:丢弃的是工作记忆里的旧消息,不是删除数据库记录 。配了 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.pyfinally 块):

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

两个关键设计:

  1. 整体覆盖,不是增量update_session_state 把整个 agent.state 序列化后整体写回 sessions.state 列------不是只写新增的消息。这与开场的"整载"对称,构成完整的整载-整存闭环。
  2. 锁内 + shield 防取消 :持久化必须在会话锁释放之前 完成(否则别的 worker 可能拿到旧状态)。asyncio.shield 保护它不被外层 CancelledError 中断。

把这点说透:整存必须在释放会话锁之前完成,否则并发读可能拿到旧状态。AgentScope 用"锁内整存"保证了会话状态的一致性------它只承诺"下一位读者读到的是最新状态",不含回滚、原子性等承诺。

锁加在哪一行 。锁不在 _persist 里,而在 _run_impl 的装配之后------async with acquire_lock(session_lock)_chat.py:798)包住的是从推理到持久化的那一段 :推理循环改 state.context_persist 整存,全在锁内。所以"锁内整存"的准确含义是位置关系------_persistasync with 块的最末尾、释放锁之前 执行。注意锁不包整载get_session(L545)在加锁之前,锁串行化的是"改 + 写"(推理 + 整存),不是"读-改-写"整个周期------整载和装配在锁外。

广播与持久化:前端先看到,真相后落库 。锁内每个事件走三步:先同步 appendreply_msg(内存里的本轮回复,不可中断),再 publish_session_event 广播给前端 (SSE/WS 流式展示),推理结束进 finally 块的 _persist落库 。前端先看到、持久化后落库,为什么不会乱?三层保护:① 锁释放必须等 _persist 完成------persist_task = create_task(_persist()) 后用 asyncio.shield 等它,async with 的退出被挡住,下一位读者拿不到旧 state;② 即使被 CancelledError 中断,也先 await persist_taskraise------被取消也要先落库再退锁;③ 前端根本不依赖持久化成功------广播是"正在发生"的实时通道,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_busRedisMessageBusSET key NX EX + 心跳续期实现跨节点分布式锁InMemoryMessageBus 只有进程内锁。myagent 当前用 InMemoryMessageBusserver.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_replyfinally 块里:

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_backlogger.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 列表里加 Mem0MiddlewareReMeMiddleware,状态流转自动补齐------检索进 on_reasoning、写回进 on_reply,无需改任何会话代码。这就是"记忆做成中间件"的收益:会话的状态流转结构不动,记忆作为横切能力插入

压缩产物要不要沉淀进记忆

文章第六章讲过压缩:旧消息被模型压成 summary,存进 state.summary,随每轮推理送进模型。但这里有个设计取舍------压缩产物只服务"当前会话的后续轮次",不会跨会话共享

你可以在 myagent 里做一个实验性的改造:把压缩产生的 summary(或 offload 落盘的旧消息)额外写入记忆库,让它在未来会话的检索里也能命中。这样:

  • 当前会话:靠 summary 维持长对话连贯(AgentScope 默认行为)
  • 未来会话:靠记忆库检索到"上次聊到哪、结论是什么"(记忆的跨会话价值)

这就是"压缩即记忆"的思路------把"整载-推理-落库"里的降级写,变成"长期记忆"的输入。它不是 AgentScope 内置行为,但你的 myagent 的状态流转结构完全能承载它:落库时多一次记忆写入,整载时多一次记忆读取。

会话与记忆是一体两面

回到开篇的核心结论:LLM 无状态,一次对话的开场要整载全部状态,结束要整存全部状态。看完七章源码,你手里现在有完整的地图:从第一章的总览图到第六章的锁时序图,你已经看过"一条消息的旅程"在每个阶段的样子------整载(第三章)、装配(第四章)、推理增长(第五章)、压缩与持久化(第六章)、记忆写回(第七章),以及 myagent 缺的那两行(本章)。

会话状态、记忆、压缩三者共同构成 Agent 的"状态"内核,各司其职:会话状态承载每次推理的上下文,记忆负责跨会话沉淀,压缩负责在上下文超长时降级。你的 myagent 已经有一半(会话状态流转),补上记忆读写的两行,它就能跨会话记得你。这也正是 Agent 从"问答机"走向"能长期协作的助手"的那一步。

相关推荐
桦说编程2 小时前
记一次 Coding Agent 改动带来的bug与启示
后端·agent·vibecoding
阿里云大数据AI技术2 小时前
阿里云 Milvus 知识库开启邀测,助力客户构建企业级 Agent
人工智能·agent
rcyw2 小时前
Agent Sandbox 不只是一个 exec:我为什么做了 Axern
agent
网易云信3 小时前
政企共建!全国首个网易智企 AI OPC 社区落地衢州
aigc·agent
阿文和她的Key3 小时前
把 800+ 文件的真实项目交给 XunOPC,它到底能不能真的修 Bug?
agent·ai编程
宋哥转AI3 小时前
深入理解 AI Agent · MEMORY #02:记忆的工程机制
人工智能·agent·ai编程
lfn3 小时前
给 Agent Skill 写 description 不再靠猜:触发率量化 + 自动修复闭环
agent
野三关彭于晏3 小时前
Agent 时代,如何用 AI 重塑教育信息化
人工智能·agent
gyx_这个杀手不太冷静4 小时前
高级前端开发职业规划(2026—2035)
前端·面试·agent