← 返回文章列表

从零构建 AI Agent(三):记忆、规划与断点恢复——从"能用"到"能合作"

前两篇记录了从零构建 Agent 的全过程和工程化进阶。写完第二篇后,我原以为下一步应该是"持续漂移检测"和"部署到云端",但实际使用中遇到的问题让我改变了方向。Agent 不记得我是谁、不知道什么时候该停、中断后一切归零、编辑文件只能全量覆盖——这些才是真正影响使用体验的痛点。本篇记录了四个核心能力的演进:基于 Tulving 1972 年论文设计三层记忆系统、让 Agent 先规划再执行的任务编排、中断后从断点恢复的 Checkpoint 机制、以及一系列从实际使用中"长出来"的改进。贯穿始终的主线是一个认知转变:**Agent 开发不是按计划推进的线性过程,而是在使用中不断发现问题、不断调整方向的 Steering Loop

agent
harness
memory

作者: Jinkun
时间: 2026年4月
项目地址: github.com/yaoziyaoguai/my-first-agent
前置阅读:


一、计划赶不上变化

写完第二篇博客时,我列了一个清晰的"下一步":

1. 持续漂移检测(cron 定时健康检查)
2. Agent 自审代码
3. 部署到阿里云服务器

这个计划看起来很合理——先完善监控,再提升智能,最后部署。但当我真正开始用自己的 Agent 做日常工作时,发现完全不是这回事。

第一个痛点:Agent 不认识我。 每次启动都是一个全新的对话。它不知道我叫什么、用什么技术栈、有什么偏好。上一次我们讨论了半天的决策,下一次全忘了。

第二个痛点:Agent 不知道什么时候该停。 让它"检查一下项目代码",它就无限循环地 ls -la → read_file → ls -la,永远觉得自己"还没看完"。

第三个痛点:中断后一切归零。 执行到一半我按了 Ctrl+C,重新启动后之前的进度全部丢失,只能从头再来。

第四个痛点:只能全量覆盖文件。 想改一行 CSS,Agent 必须重写整个文件,改着改着接口全变了。

这四个痛点没有一个在我的原始计划里。它们全部来自实际使用——这恰好印证了 Harness Engineering 的 Steering Loop:你不可能预先设计出完美的系统,只能在使用中发现问题,然后迭代。

于是我调整了方向:

原计划:漂移检测 → 自审代码 → 部署
实际路径:记忆系统 → 任务规划 → 断点恢复 → 局部编辑 → 一系列意外的改进

二、记忆系统:让 Agent 认识我

从 Tulving 到工程实现

在设计记忆系统之前,我读了一篇 1972 年的认知心理学论文——Endel Tulving 的《Episodic and Semantic Memory》。这篇论文提出的记忆分类框架,在 50 年后的今天几乎可以直接映射到 AI Agent 的记忆设计上(详见我的另一篇博客)。

三层记忆的对应关系:

Tulving 的分类Agent 中的实现存储位置
情景记忆(Episodic)发生过的关键事件memory/episodes/*.jsonl
语义记忆(Semantic)用户偏好、项目知识memory/profile.json
程序性记忆(Procedural)行为规则和操作流程memory/rules/*.md

用一个具体例子来理解三者的关系:

情景记忆:"2026-04-10 用户让 Agent 读 .env,API Key 进入了 messages"
    ↓ 提炼
语义记忆:".env 文件包含敏感信息"
    ↓ 内化
程序性记忆:"读文件前检查敏感文件模式,匹配则直接拒绝"

信息的流向是:事件 → 知识 → 行为。

存储设计:不用框架,纯 JSON 文件

考虑过向量数据库和各种记忆框架(Mem0、Zep、LangMem),但作为个人使用的本地 Agent,复杂度太高。最终选择了最简单的方案:

memory/
├── profile.json              ← 语义记忆
│   {
│     "user": {"name": "Jinkun", "tech_stack": [...]},
│     "preferences": {"code_style": "偏好类型注解", ...},
│     "knowledge": [{"fact": "...", "confidence": "high", "reason": "..."}],
│     "projects": {"my-first-agent": {...}}
│   }
│
├── episodes/                 ← 情景记忆(每天一个文件,保留20天)
│   └── 2026-04-10.jsonl
│
└── rules/                    ← 程序性记忆(每个行为模式一个文件)
    ├── tool_usage.md
    ├── file_and_code.md
    ├── high_risk_ops.md
    └── ...

召回时机的设计

一个关键的设计决策:什么时候把记忆塞进上下文?

最初的设计是每次用户输入后都搜索情景记忆。但讨论后发现这不对——语义记忆(知识)已经在启动时加载到了 system prompt 里,它是常驻的。大多数时候用户提问需要的是知识,不是历史事件。

情景记忆什么时候有用?只有用户主动引用过去的时候——"上次我们做的那个东西"、"之前那个 bug 怎么解决的"。

最终的召回设计:

Agent 启动时:
  → profile.json + rules/*.md → 注入 system prompt(常驻)

用户输入后:
  → 通常不搜索情景记忆
  → 只有用户提到"上次""之前""继续"等词时才搜索 episodes

Session 结束时:
  → LLM 从对话中提取新的记忆 → 更新三个存储

System Prompt 的瘦身

有了记忆系统后,system prompt 发生了一次质的变化。之前我写了一个将近 3000 字符的 system prompt,包含了身份、原则、工具规则、文件处理规则、高风险操作规则、输出格式、沟通风格……

问题是:这些规则硬编码在 config.py 里,每改一条都要改代码。而且它在每次 API 调用时都完整发送,占用上下文窗口。

改进方案:system prompt 只保留核心身份(~300 字符),所有详细规则拆到 memory/rules/ 下。

# 之前:3000+ 字符的 system prompt
SYSTEM_PROMPT = """你是一个通用智能 Agent...
[工具使用规则]...
[文件处理规则]...
[高风险操作规则]...
[输出组织规则]...
[沟通风格]...
"""

# 之后:~300 字符的核心身份
SYSTEM_PROMPT = """你是一个通用智能 Agent。
你的职责是理解用户的真实目标...

核心原则:
1. 目标导向...
2. 先判断后行动...
3. 真实可靠...
"""
# 详细规则通过 build_memory_prompt() 从 memory/rules/ 加载

好处是三重的:

  1. 可维护性:改一条规则只改一个 .md 文件,不用改 config.py
  2. 可扩展性:Agent 以后可以自己往 rules/ 里写新规则
  3. 为选择性加载留了口子:虽然现在全量加载,但数据已经分开了

记忆提取的三次迭代

记忆系统最难的不是存储和加载,而是从对话中提取什么。

第一版提取 prompt:

提取规则:
1. episodes:关键事件
2. knowledge:新发现的通用知识或事实
3. rules:新的行为规则或教训

结果:提取了 25 条 knowledge,但全是项目实现细节——"fetch_url 超时时间为 15 秒"、"ruff 输出截断至 2000 字符"。这些跟我这个人没有任何关系,只是 Agent 在执行任务时的输出。

问题分析: prompt 说"新发现的通用知识或事实",模型就把它看到的所有"事实"都记下来了。它不知道我们真正想记住的是关于用户的信息。

第二版提取 prompt——核心改动:

2. knowledge:从对话中提取关于用户的长期偏好和决策习惯,重点关注:
   - 用户主动表达的观点和偏好("我觉得..."、"我更偏向...")
   - 用户在多个方案中的选择,以及选择的理由
   - 用户对建议的接受或拒绝
   - 用户反复提及或深入追问的话题
   - 用户有明显情绪反应的观点和决策
   - 尽量将具体行为抽象为习惯或模式
   不要提取:
   - 模型单方面的输出内容(代码细节、工具返回值、具体参数)
   - 没有经过用户确认或选择的信息
   - 一次性的任务细节

关键转变:从"提取事实"变成了"提取关于用户的洞察"。而且加了反面约束——这是之前在工具描述调优中学到的技巧:告诉模型"不要做什么"往往比"要做什么"更有效。

第二版结果: knowledge 从 25 条变成了 0 条。太严格了?其实不是——测试那次对话里我确实没有表达过偏好。从 25 条垃圾到 0 条空白是进步,因为错误的记忆会误导 Agent,空的记忆只是还没积累。

还给 knowledge 加了一个 reason 字段——记录"为什么提取这条"。比如:

{
  "fact": "用户偏好先理解概念再动手",
  "confidence": "high",
  "reason": "用户多次在学习新功能时要求先解释为什么再写代码"
}

这样回顾记忆时能知道每条知识的来源,也帮助模型更好地理解这条偏好的适用场景。


三、任务规划:让 Agent 知道什么时候该停

无限循环的根因

之前让 Agent "检查一下项目代码",它的行为是:

ls -la → read_file(main.py) → read_file(config.py) → ls -la → read_file(core.py) → ls -la → ...

87 条消息,一直循环。工具调用次数限制(MAX_TOOL_CALLS_PER_TURN = 20)最终拦住了它,但这只是治标。

根本原因是:Agent 每一步只做一个决策——"下一步干什么"。它没有一个全局的计划说"我总共要做这五件事,做完就停"。

规划机制的设计

方案是在执行前多调一次模型,专门做任务判断:

用户输入
  ↓
generate_plan(独立 API 调用)→ 判断需要几步 → 生成计划
  ↓
展示计划给用户确认
  ↓
用户确认后,计划注入 messages → Agent 按计划执行
  ↓
所有步骤完成 → 自动停止

谁来判断"需不需要规划"?

最初让模型判断 needs_plan: true/false,但效果不好。用户说"帮我看一下 agent 文件夹下的代码,一步一步做",模型判断"不需要计划"——因为它没有把"文件夹"推理成"多个文件"。

后来改成让模型估算步骤数(steps_estimate),代码判断 > 1 就需要计划。但本质上还是推理型控制,模型的判断不完全可靠。

最终的方案是三层结合:

1. /plan 指令 → 用户主动触发,最可靠
2. 自动判断 → 兜底,不准也没关系
3. rules 里的行为规则 → Agent 执行工具前简要说明意图

关于 /plan 指令的灵感来自 Claude Code——让用户主动告诉 Agent "这个任务请先规划",而不是让模型猜。计算型触发比推理型判断可靠得多。

规划器的独立性——一个重要的工程教训

最初给规划器传了最近 6 条对话历史,希望它能理解上下文。结果全部报错——json.loads 收到空字符串。

调试后发现:模型看到历史消息后,角色混乱了。 system prompt 说"输出 JSON",但历史消息里的对话模式更强烈,模型延续了聊天角色而不是结构化输出。

[DEBUG] plan raw response: '**收到确认**,按新计划执行深度代码分析...'

它输出的是聊天内容,不是 JSON。

教训:需要模型输出结构化数据的独立调用,不要混入对话历史。 对话上下文和结构化输出是两种不同的"模式",混在一起模型会混淆。

去掉历史消息后,规划器只看当前输入,反而工作得更好。而且发现一个有趣的现象——即使规划器生成了一个"模糊的计划"(因为看不到上下文),主对话模型能结合完整历史把细节补上:

规划器(无历史):
  步骤1:分析当前上下文
  步骤2:生成报告
  → 模糊但结构正确

主对话模型(有完整历史):
  看到"分析当前上下文" → 知道是指 agent/ 目录
  → 填入具体细节执行

模糊的计划 + 完整的历史 = 有效的执行。 计划提供结构(几步做完就停),历史提供内容(具体做什么)。


四、断点恢复:中断不再是灾难

设计决策:保存什么?

断点恢复需要在中断时保存状态,下次启动时恢复。核心问题是保存什么。

方案一:追踪步骤号。 记录"当前执行到第几步"。但怎么知道"这一步完成了"?让模型输出 [STEP N] 标记?这是推理型控制,模型可能不遵守。

方案二:保存 messages 历史。 不追踪步骤,保存已有的对话记录。恢复时模型自己判断做到哪了——这正是模型擅长的。

选了方案二。配合一个小优化:保存时截断 tool_result 里的大块内容,但加上"[此步骤已成功完成]"的标记,防止恢复后模型以为"上次没读完"而重做。

三种退出方式

quit              → 正常退出:提取记忆 + 保存快照 + 保留断点
单次 Ctrl+C       → 暂停任务:保存断点 + 给用户选择(继续/放弃/退出)
连续两次 Ctrl+C   → 强制退出:保存快照 + 保留断点

quit 是"我做完了",Ctrl+C 是"我先走了,回头继续"。两者的行为不同——quit 时提取记忆(对话完整),Ctrl+C 时不提取记忆(对话不完整,提取质量差)。

Checkpoint 的生命周期

计划确认时       → save_checkpoint(创建)
执行过程中       → checkpoint 保持不变
Ctrl+C 中断时    → save_checkpoint(更新最新 messages)
任务正常完成时    → clear_checkpoint(清除)
下次启动时       → load_checkpoint → 提示用户是否继续

五、意外的演进:使用中"长出来"的改进

以下几个改进都不在原始计划里,全部来自实际使用中遇到的问题。

edit_file:局部编辑

之前只有 write_file(全量覆盖)。想改一行代码,Agent 必须重写整个文件,经常把不该改的也改了。

解决方案出奇地简单——实现一个查找替换工具:

@register_tool(name="edit_file", ...)
def edit_file(path, old, new):
    content = file_path.read_text()
    if content.count(old) > 1:
        return "匹配到多处,请提供更精确的内容"
    updated = content.replace(old, new, 1)
    file_path.write_text(updated)

模型先用 read_file 看到完整内容,然后指定"把什么改成什么"。不需要行号,不需要 diff,不需要 copy。核心逻辑就是 str.replace。

smart shell 确认

痛点是每次执行 shell 命令都要按 y,即使是 ls -la 这种无害命令。把 shell 命令分成两类:

READONLY_COMMANDS = {"ls", "cat", "find", "grep", "wc", "head", "tail", "pwd", ...}

def _check_shell_confirmation(tool_input):
    command = tool_input.get("command", "").strip()
    first_word = command.split()[0]
    if first_word in READONLY_COMMANDS:
        return False  # 自动执行
    return True       # 需确认

一个担心是 cat .env 也是只读命令但应该被拦截。但不需要在确认层处理——run_shell 函数内部已经有敏感文件检查了。确认机制和安全检查是两层独立的控制:

确认机制:决定"要不要问用户" → cat 是只读 → 不问
安全检查:决定"允不允许执行" → .env 是敏感文件 → 拒绝

防循环检测

Agent 反复调用同一个工具、传同样的参数。计算型检测:

call_signature = f"{tool_name}:{json.dumps(tool_input, sort_keys=True)}"
recent_calls.append(call_signature)
if len(recent_calls) >= 3 and len(set(recent_calls[-3:])) == 1:
    result = "检测到重复调用,请基于已有信息继续下一步"

连续拒绝强制停止

用户连续按 n,Agent 不断换方式尝试达到同一个目标。tool_result 里的"请停下来"是推理型控制,模型可能不遵守。加了计算型兜底:

consecutive_rejections += 1
if consecutive_rejections >= 3:
    return "用户连续拒绝了多次操作,任务已停止。"

直接 return 跳出 while 循环,模型没有机会继续。这是计算型控制兜底推理型控制的又一个实例。

上下文窗口优化

Kimi-k2.5 提供 256K token 的上下文窗口(约 50 万字符),但原来的压缩配置太激进:

# 之前:只用了 10% 就开始压缩
MAX_MESSAGES = 10
MAX_MESSAGE_CHARS = 50000

# 之后:用到 80% 才压缩
MAX_MESSAGES = 100
MAX_MESSAGE_CHARS = 400000

之前 Agent 反复读同一个文件的原因之一就是压缩太早——有用的历史被压缩掉了,模型"忘记"自己读过什么。调大阈值后,模型在大多数任务里都能看到完整的历史。

用户拒绝的反馈机制

之前用户按 n,tool_result 只说"用户拒绝了此操作"。模型不知道为什么被拒绝,就换一种方式重试。

改成三种返回值:

# confirm_tool_call 的返回值:
True    → 同意执行
False   → 拒绝,无理由
"文字"  → 拒绝,带反馈意见

# core.py 里的处理:
if approved is True:
    result = execute_tool(...)
elif isinstance(approved, str):
    result = f"用户拒绝了此操作,反馈如下:{approved}"
else:
    result = "用户拒绝了此操作。请停下来询问用户需要什么调整。"

六、两个重要的认知转变

从"按计划推进"到"在使用中发现方向"

写完 Blog 2 时,我的计划是"漂移检测 → 自审代码 → 部署"。实际走的路是"记忆 → 规划 → 断点 → 编辑工具 → 一系列意外的改进"。没有一个重大改进是原始计划里预见到的。

这不是计划失败,这就是 Steering Loop 的本质。Harness 原文说"Harness 是长出来的,不是设计出来的"——记忆系统、任务规划、断点恢复,全都是在使用中遇到痛点后才去做的。

启示:不要试图在开始之前想清楚所有事情。先跑起来,用起来,痛点会自己告诉你下一步该做什么。

从"控制 Agent 的输出"到"与 Agent 合作"

前两篇的主线是 Harness——怎么约束 Agent、怎么防止它做错事。本篇的主线变了——怎么让 Agent 更懂我、怎么让交互更顺畅。

记忆系统按某种角度也是Harness子集,它也是 Context Engineering——让模型看到更好的信息。任务规划是 Harness(Guide)和 Context Engineering(注入计划到上下文)的结合。断点恢复是纯工程。

这说明随着 Agent 能力的成熟,关注点会从"防御"转向"协作"。前期你担心的是"Agent 会不会搞坏我的文件",后期你关心的是"Agent 能不能更高效地帮我做事"。


七、踩坑清单

问题根因解决方案
记忆提取全是项目细节提取 prompt 太宽泛重写 prompt 聚焦用户偏好 + 加反面约束
规划器返回聊天内容而非 JSON传了历史消息导致角色混乱规划调用不带历史,只传当前输入
模型判断"不需要计划"但实际需要推理型判断不可靠加 /plan 指令让用户主动触发
ls -la 也要确认很烦shell 全部 always 确认只读命令白名单自动执行
按 n 后 Agent 换方式继续重试推理型"请停下来"被忽略连续拒绝 3 次代码层面强制 return
config.py 自己导入自己编辑时误加了 from config import删除循环导入
压缩太早导致模型"失忆"256K 窗口只用了 10%阈值调到 80%
模糊计划反而能工作规划器无历史但主对话有两者各司其职:结构 + 内容

八、当前完整架构

my-first-agent/
│
├── config.py                      ← 配置中心(精简版 System Prompt ~300字符)
├── main.py                        ← 入口 + 启动检查 + 断点恢复 + Ctrl+C 处理
├── .pre-commit-hook.sh            ← Git 提交前检查
│
├── agent/
│   ├── core.py                    ← Agent Loop(防循环 + 连续拒绝停止)
│   ├── tool_registry.py           ← 注册中心(装饰器 + 钩子)
│   ├── planner.py                 ← 任务规划(/plan 指令 + 自动判断)
│   ├── checkpoint.py              ← 断点恢复
│   │
│   ├── tools/                     ← 7 个插件化工具
│   │   ├── calc.py                ← calculate(AST 安全版)
│   │   ├── file_ops.py            ← read_file + read_file_lines
│   │   ├── write.py               ← write_file + pre/post 钩子
│   │   ├── edit.py                ← edit_file(局部编辑)
│   │   ├── shell.py               ← run_shell(smart 确认 + 四层防护)
│   │   ├── web.py                 ← fetch_url
│   │   └── outline.py             ← 多格式结构提取
│   │
│   ├── security.py                ← 通用安全(确认、敏感文件、脚本检测)
│   ├── context.py                 ← 上下文压缩(256K 窗口优化)
│   ├── review.py                  ← 跨模型审查 + 自动重试
│   ├── checks.py                  ← 运行时 linter 检查
│   ├── health_check.py            ← 启动健康检查
│   ├── memory.py                  ← 三层记忆系统
│   └── logger.py                  ← 日志 + 快照
│
├── memory/
│   ├── profile.json               ← 语义记忆
│   ├── episodes/                  ← 情景记忆(保留 20 天)
│   └── rules/                     ← 程序性记忆(8 个行为规则文件)
│
└── workspace/                     ← Agent 工作区

九、核心认知(续)

计划型控制 vs 计算型控制的反复印证

每次遇到行为问题,解决路径都是同一个模式:

推理型控制(告诉模型"请不要...")→ 模型可能不遵守
  ↓
计算型控制(代码层面强制)→ 可靠但可能太粗暴
  ↓
两者结合 → 推理型先引导,计算型兜底

连续拒绝停止、工具调用上限、防循环检测——全都是这个模式。

Context Engineering 的细节决定一切

记忆提取 prompt 改了几个词,效果从"25 条垃圾"变成"精准提取用户偏好"。工具描述加一句反面约束,误触发就消失了。规划器去掉历史消息,JSON 解析就正常了。

你注入到上下文里的每一个字都会影响模型的行为。 这不是夸张——一个 [此步骤已成功完成] 的标记就能决定 Agent 恢复时是从断点继续还是从头重做。

模糊的分工反而更好

规划器不需要很精确——它提供结构("分几步做完"),主对话模型填充内容("具体做什么")。记忆系统不需要完美——它提供背景,模型在具体场景中灵活运用。

Agent 开发中最常见的错误是过度设计——试图在代码层面控制每一个细节。但模型本身就擅长理解上下文和灵活决策,很多时候你只需要给它正确的信息和合理的边界。

使用者的痛点是最好的路标

原始计划里的"漂移检测"和"部署到云端"不是不重要,而是还没到时候。真正的优先级应该由使用体验决定——你天天按 yes 的时候不会想着"我应该做漂移检测",你只想着"能不能不按这个 yes"。


十、下一步

  1. 真实场景验证:用 Agent 做一些真实的工作(修改博客代码、分析数据、读技术文档),在使用中发现下一批痛点
  2. 记忆系统的长期效果:观察随着对话积累,knowledge 和 rules 的质量是否真的在提升
  3. 性能优化:长对话场景下的 token 消耗、API 调用延迟
  4. 部署到阿里云:当本地功能足够成熟时

但我已经不再为下一步写详细的计划了。先用起来,痛点会告诉我该做什么。


本文所有代码均为作者亲手编写和调试。学习过程中与 Claude 协作,采用苏格拉底式教学。如果你也在做 Agent 开发,欢迎交流:wangjinkun333.me

分享这篇文章

复制链接,或分享到你常用的地方。

评论

发表评论

0 / 1000

KEEP READING

全部文章