再聊 nanobot 的记忆机制:从消息计数到 Token 预算

Posted on 六 21 3月 2026 in AI

再聊 nanobot 的记忆机制:从消息计数到 Token 预算

上次写过一篇 nanobot 记忆机制的文章,聊了双层记忆(MEMORY.md + HISTORY.md)的基本设计。那篇文章发出去之后,nanobot 的代码又迭代了不少——从 4000 行长到了 15000 行,记忆系统也有了几个重要的变化。

这篇算是续集,聊三件事:

  1. 整理策略从"消息计数"升级到了"Token 预算"
  2. 会话历史加了一道"合法性校验"
  3. 一个真实的 bug:subagent 结果导致连续 assistant 消息

一、从消息计数到 Token 预算

上次说的整理触发条件是"未整理消息超过 100 条就触发"。这个方案简单,但有个明显的问题:100 条消息的 token 数差异巨大

一条"在吗?"只有几个 token,一条包含完整代码文件的工具调用结果可能有上万 token。用消息条数做阈值,要么整理太频繁(浪费 API 调用),要么整理太晚(prompt 撑爆上下文窗口)。

新版本改成了基于 Token 预算的策略。核心逻辑在 MemoryConsolidator.maybe_consolidate_by_tokens() 里:

async def maybe_consolidate_by_tokens(self, session: Session) -> None:
    target = self.context_window_tokens // 2
    estimated, source = self.estimate_session_prompt_tokens(session)

    if estimated < self.context_window_tokens:
        return  # 还没撑爆,不用整理

    # 循环整理,直到 prompt 大小降到窗口的一半以下
    for round_num in range(self._MAX_CONSOLIDATION_ROUNDS):
        if estimated <= target:
            return

        boundary = self.pick_consolidation_boundary(session, max(1, estimated - target))
        if boundary is None:
            return

        end_idx = boundary[0]
        chunk = session.messages[session.last_consolidated:end_idx]
        if not await self.consolidate_messages(chunk):
            return

        session.last_consolidated = end_idx
        self.sessions.save(session)
        estimated, source = self.estimate_session_prompt_tokens(session)

几个关键设计:

目标是窗口的一半。不是刚好卡在上限,而是留出充足的余量。这样整理完之后,用户还能聊很多轮才需要再次整理。

循环整理。一次可能不够——如果积压了大量消息,可能需要多轮整理才能把 prompt 压到目标以下。最多 5 轮(_MAX_CONSOLIDATION_ROUNDS),防止无限循环。

边界选择只在 user 消息处切割pick_consolidation_boundary() 遍历消息列表,只在 role == "user" 的位置设置切割点。为什么?因为一个完整的"对话轮次"是 user → assistant(tool_calls) → tool(results) → assistant(response)。如果在 assistant 和 tool 之间切割,就会产生"孤儿"消息——tool result 找不到对应的 tool_calls,或者 tool_calls 找不到对应的 result。

def pick_consolidation_boundary(self, session, tokens_to_remove):
    start = session.last_consolidated
    removed_tokens = 0
    last_boundary = None

    for idx in range(start, len(session.messages)):
        message = session.messages[idx]
        if idx > start and message.get("role") == "user":
            last_boundary = (idx, removed_tokens)
            if removed_tokens >= tokens_to_remove:
                return last_boundary
        removed_tokens += estimate_message_tokens(message)

    return last_boundary

Token 估算用 tiktokenestimate_message_tokens()cl100k_base 编码器估算每条消息的 token 数。不精确,但够用——我们只需要一个大致的量级来决定是否需要整理。

二、会话历史的合法性校验

即使边界选择很小心,还是可能出现"不合法"的历史。比如:

  • 进程崩溃导致 session 文件写了一半
  • 旧版本的 bug 留下了不一致的数据
  • 手动编辑了 session 文件

所以 get_history() 在返回历史之前,会做一道合法性校验:

def get_history(self, max_messages=500):
    unconsolidated = self.messages[self.last_consolidated:]
    sliced = unconsolidated[-max_messages:]

    # 跳过开头的非 user 消息
    for i, message in enumerate(sliced):
        if message.get("role") == "user":
            sliced = sliced[i:]
            break

    # 检查孤儿 tool result
    start = self._find_legal_start(sliced)
    if start:
        sliced = sliced[start:]

    # 合并连续 assistant 消息(防御性)
    return self._fix_consecutive_assistants(out)

_find_legal_start() 的逻辑是:从头扫描消息列表,维护一个"已声明的 tool_call_id"集合。每遇到 assistant 消息的 tool_calls,就把 id 加入集合;每遇到 tool result,就检查它的 tool_call_id 是否在集合里。如果不在——说明对应的 assistant 消息被截掉了——就把起始位置推到这条 tool result 之后。

@staticmethod
def _find_legal_start(messages):
    declared = set()
    start = 0
    for i, msg in enumerate(messages):
        if msg.get("role") == "assistant":
            for tc in msg.get("tool_calls") or []:
                if isinstance(tc, dict) and tc.get("id"):
                    declared.add(str(tc["id"]))
        elif msg.get("role") == "tool":
            tid = msg.get("tool_call_id")
            if tid and str(tid) not in declared:
                start = i + 1  # 跳过这条孤儿 tool result
                declared.clear()
    return start

这个校验解决了一类常见的 400 错误:Anthropic API 要求每个 tool result 都能在前面的 assistant 消息中找到对应的 tool_call_id,否则直接拒绝请求。

不过它有一个盲区:只检查"孤儿 tool result",不检查"缺失 tool result"。如果 assistant 声明了 tool_calls=[A, B],但只有 tool(A) 的结果,tool(B) 丢了——_find_legal_start 不会发现这个问题。好在正常流程中不会出现这种情况,因为 _run_agent_loop 总是会执行所有 tool call 并添加所有结果。

三、一个真实的 bug:连续 assistant 消息

说个刚修的 bug,挺有代表性的。

背景

nanobot 支持 subagent——主 Agent 可以用 spawn 工具创建子 Agent 异步执行任务。子 Agent 完成后,结果通过消息总线发回主 Agent。

有人提了一个 PR(commit f72ceb7),把 subagent 结果的消息角色从 user 改成了 assistant

# 改动前
current_role = "user"

# 改动后
current_role = "assistant" if msg.sender_id == "subagent" else "user"

意图是好的:subagent 的结果确实更像是 assistant 的输出,而不是用户的输入。但这个改动引入了一个微妙的问题。

问题

当 subagent 结果作为 assistant 角色注入后,LLM 可能会回复 tool_calls。这时消息序列变成:

[...history...]
assistant("subagent 完成了,结果是...")     ← 注入的 subagent 结果
assistant(tool_calls=[Y])                   ← LLM 的回复
tool(tool_call_id=Y, content="...")         ← 工具执行结果
assistant("处理完毕")                       ← LLM 的最终回复

两条连续的 assistant 消息!这些消息被 _save_turn 保存到 session 后,下次用户发消息时,get_history() 会把它们原样返回。

Anthropic 的 API 不允许连续的同角色消息。通过 LLM Gateway 转发时,gateway 尝试合并这两条 assistant 消息,但合并过程中 tool_calls 的配对关系被破坏了。于是报错:

'tool_call_id' of 'toolu_011hc7X68Zw7n1eWkDoNdtZN' not found in 'tool_calls' of previous message

修复

方案 B:保留 assistant role 的语义,但修复保存逻辑。

第一层:不保存注入的 assistant 消息。subagent 结果是当前轮次的上下文,不是真正的对话历史。保存时跳过它:

save_skip = 1 + len(history)
if current_role == "assistant":
    save_skip += 1  # 跳过注入的 assistant 消息
self._save_turn(session, all_msgs, save_skip)

第二层:防御性合并。在 get_history() 返回之前,扫描并合并连续的 assistant 消息。即使 session 里已经有了脏数据(比如修复前产生的),也能兜住:

@staticmethod
def _fix_consecutive_assistants(messages):
    result = []
    for msg in messages:
        if (result
            and msg.get("role") == "assistant"
            and result[-1].get("role") == "assistant"
            and not result[-1].get("tool_calls")):
            # 前一条 assistant 没有 tool_calls,合并内容到当前消息
            prev_content = result[-1].get("content") or ""
            cur_content = msg.get("content") or ""
            merged = f"{prev_content}\n\n{cur_content}".strip()
            result[-1] = dict(msg)
            if merged:
                result[-1]["content"] = merged
        else:
            result.append(msg)
    return result

合并规则:只合并"前一条没有 tool_calls"的情况。如果前一条有 tool_calls,说明它后面应该跟 tool results,不能随便合并。

教训

这个 bug 的教训是:改消息角色要非常小心。LLM API 对消息序列有严格的约束(尤其是 Anthropic),而这些约束不仅影响当前请求,还会通过 session 持久化影响后续所有请求。一个看似无害的 role 改动,可能在几轮对话之后才暴露问题。

四、记忆系统的全景图

把上面说的串起来,nanobot 的记忆系统现在长这样:

用户发消息
    │
    ▼
┌─────────────────────────────────────────┐
│  ContextBuilder.build_messages()        │
│  ┌─────────────────────────────────┐    │
│  │ System Prompt                   │    │
│  │  ├─ 身份 (nanobot 🐈)          │    │
│  │  ├─ AGENTS.md / SOUL.md / ...  │    │
│  │  ├─ MEMORY.md (长期记忆全文)    │    │
│  │  └─ Skills 列表                │    │
│  ├─────────────────────────────────┤    │
│  │ History (未整理的消息)          │    │
│  │  └─ _find_legal_start 校验     │    │
│  │  └─ _fix_consecutive_assistants│    │
│  ├─────────────────────────────────┤    │
│  │ 当前用户消息                    │    │
│  └─────────────────────────────────┘    │
└─────────────────────────────────────────┘
    │
    ▼
  LLM 推理 ←→ 工具调用循环
    │
    ▼
┌─────────────────────────────────────────┐
│  _save_turn()                           │
│  ├─ 截断过长的 tool result              │
│  ├─ 剥离 runtime context 前缀           │
│  ├─ 跳过空 assistant 消息               │
│  └─ 追加到 session.messages             │
└─────────────────────────────────────────┘
    │
    ▼
┌─────────────────────────────────────────┐
│  maybe_consolidate_by_tokens()          │
│  ├─ 估算当前 prompt token 数            │
│  ├─ 超过上下文窗口?                    │
│  │   ├─ 选择 user 消息边界              │
│  │   ├─ 取出待整理的消息块              │
│  │   ├─ 调用 LLM 做摘要                │
│  │   │   ├─ history_entry → HISTORY.md  │
│  │   │   └─ memory_update → MEMORY.md   │
│  │   └─ 推进 last_consolidated          │
│  └─ 循环直到 prompt < 窗口/2            │
└─────────────────────────────────────────┘

整个流程的设计哲学没变:简单、透明、可恢复。两个 Markdown 文件,一个 JSONL session,一个整数指针。没有向量数据库,没有知识图谱,没有复杂的检索管线。

对于个人助手这个场景,这个方案的性价比很高。你的偏好、项目上下文、工作习惯——这些信息加起来也就几 KB 到几十 KB,完全塞得进一个 prompt。当信息量大到塞不进去的时候,HISTORY.md 的 grep 搜索就是你的后备方案。

不够优雅?也许。但它管用,而且你随时可以打开 MEMORY.md 看看 AI 到底记住了什么。这种透明度,是很多"高级"记忆方案做不到的。


项目地址:github.com/HKUDS/nanobot 上一篇:nanobot 的记忆机制:它为什么能记住你的习惯和喜好?