Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

第 5 章:让它“懂规矩“ — Skills 与 Memory

一句话定位:你会把“怎么做某类事“沉淀成可复用的 Skill,把“用户的偏好和过往结论“沉淀成跨会话的 Memory —— agent 不再每次都重新学一遍。

想要什么

第 4 章给了 agent “手”(工具)和“脑子写代码“的能力(Code Mode)。但它还缺两样东西:

第一,做事的风格。你说“帮我 review 一下 src/agent.ts“,它会读文件,会评论,但每次评论的角度都不一样 —— 这次盯命名,下次盯异常处理,再下次盯性能。你心里有一个稳定的 checklist,它没有。

第二,跨会话的记忆。你在 thread A 跟它说“我用 npm,不用 pnpm“,thread B 它又问你一遍包管理器。你说“我们的代码 lint 走 biome,不走 eslint“,换个会话它又给你写 eslint 配置。这些是“关于你“的稳定事实,不该淹在某个会话历史里。

我们要让 agent 同时拥有:Skill(方法论,可复用,声明式)和 Memory(事实,跨会话,自动抽取)。

为什么

很多人把这两件事都叫“prompt engineering“,然后塞进 system prompt 一刀切 —— 结果 system prompt 长到 5KB,每次调用都全文重发,贵且难维护。

更好的拆法,跟着 Project Think 的设计来:

  • Skill:任务方法论。用文本描述“做某类任务时,怎么思考、按什么步骤、检查什么“。它是对所有用户、所有会话都一样的内容,可以版本化、可以 review、可以多个 Skill 按需挂载。
  • Tool:单步动作writeFilegrepsendEmail。它做事,不思考。
  • Memory:关于这个用户/项目的稳定事实。“用户偏好 npm”,“项目用 biome”,“上次部署在 thursday 失败过”。它跨会话存在,从对话里自动抽取,需要时按相关性召回。

三者各管一摊,组合起来才能让 agent 既“懂方法“,又“会动手“,又“记得人“。

跳过这一章,你的 agent 就是个“通才打工人“:每次都要从零交代背景。加上,它就是个“懂你的项目老搭档“。

图 5-1:Skill / Tool / Memory 三件套

Skill 是方法论(静态文本),Tool 是动作(代码),Memory 是事实(自动抽取) Skill / Tool / Memory:各管一摊 Skill 方法论 / checklist code-review.md refactor-plan.md api-design.md 来源:你写的 .md 文件 作用域:全用户共享 注入:configureSession 何时用:每会话开始 Tool 单步动作 / 代码执行 readFile() writeFile() execute(code) 来源:你写的 TS 函数 作用域:agent 实例 注入:getTools() 何时用:LLM 决定调 Memory 关于用户/项目的事实 "用 npm 不用 pnpm" "lint 用 biome" "timezone Asia/Shanghai" 来源:对话里自动抽 作用域:profile(跨会话) 注入:beforeTurn 召回 何时用:相关时按需

方案选择

需求理由
教 agent “如何做 code review”Skill方法论稳定,所有用户都用同一份;configureSession 一次插入
教 agent “如何调用我的 GitHub API”Tool是动作,不是思考;LLM 决定何时调
让 agent 记住 “我用 npm”Memory跨会话事实;用户告诉过一次,以后都该记得
给 agent 一个一次性的“这次任务的背景“system prompt 临时段一次性,不值得抽 Skill / Memory

简单决策路径:稳定方法 → Skill;运行动作 → Tool;关于人/项目的事实 → Memory。

Memory 在 2026 Q2 是 私测 (private beta),需要 waitlist。本章给出标准用法和 binding 形态;你可以现在就先把代码写好,等 binding 开放直接生效。生产前请先看 Agent Memory blog 的 GA 时间表。

落地

第一部分:文本 Skill — 通过 Session context block

写一个 Skill

按 BLUEPRINT_v2,Skill 放 src/skills/:

src/
└── skills/
    └── code-review.md
<!-- src/skills/code-review.md -->
# Skill: code-review

当用户要求你 review 代码时,按以下流程做。

## 1. 先看入口

- 找到这个文件被谁调用、它调用谁。先理解上下文,再点评。
- 如果是 class,先看 public 方法。

## 2. 按 checklist 评

依次检查:

- 命名:函数/变量名能不能直接读出意图?
- 异常:外部 IO 是否都包了 try/catch?是否吞了原始错误信息?
- 资源:文件句柄、stream、subscription 是否在所有路径上都释放?
- 边界:空数组、null、超长字符串、并发是否考虑?
- 测试可达:有没有副作用嵌入到难以测试的位置?

## 3. 输出格式

- 严重问题(可能产 bug):列在 ## Critical
- 改进建议(风格/可读性):列在 ## Suggestions
- 不要给"看起来不错"这种话 —— 没问题就直接说"无需改动"。

## 4. 不做的事

- 不重写整个文件,只指出问题 + 给最小改动建议。
- 不引入新依赖。
- 不评论格式问题(交给 formatter)。

这是一个纯文本文件,本质是一段 system prompt 的增量。不是代码,不是 schema,就是写给 LLM 看的方法论说明。

把 Skill 装进项目

Workers 不能直接读运行时文件系统。我们用 ESM 的字符串 import:

// src/skills/index.ts
// 用 wrangler 的 rules 把 .md 当 text 引入
import codeReview from "./code-review.md";

export const SKILLS = {
  "code-review": {
    description:
      "Code review 任务的步骤与 checklist。涉及'review/审查/检查/评审 代码'时启用。",
    content: codeReview,
  },
};

还要给 TypeScript 一个声明,让它认得 import xxx from "./xxx.md":

// src/types/markdown.d.ts
declare module "*.md" {
  const content: string;
  export default content;
}

wrangler.jsonc 加 rules,告诉 wrangler .md 当文本编译进 worker:

// wrangler.jsonc(增量)
{
  // ... 前面字段不变
  "rules": [
    { "type": "Text", "globs": ["**/*.md"], "fallthrough": true }
  ]
}

configureSession 里插入 Skill

// src/agent.ts —— 在 ch04 基础上,在 configureSession 里加一个 context block
import { SKILLS } from "./skills";

// configureSession 里追加:
.withContext("code-review-skill", {
  description: SKILLS["code-review"].description,
  // 重要:Session.withContext 的 SDK 实际签名是 { description, provider }
  // 不是 { description, content }(后者是 announcement 里的速记写法)
  // provider 必须实现 .get():Promise<string>
  provider: { get: async () => SKILLS["code-review"].content },
})
.withCachedPrompt(); // 让重复 prompt 走 prompt caching,省 token

session.withContext(name, { description, provider }) 是 Project Think 的核心 API:它把 provider.get() 返回的字符串作为一个独立的 context block 挂到这个 session 上,框架在每轮请求时拼到 LLM 的 system 段。name 是稳定的 key,后续可以替换或删除同名 block。

实测踩过:announcement 里写的 withContext(name, { content }) 是简化记法,真 SDK 必须传 provider: { get: async () => string },不然 tsc 报“missing required property ‘provider’“。我们的 SKILLS["code-review"].content 是个静态字符串,所以 provider 写一行 lambda 即可。

description 给 LLM 看 —— 它是元描述,告诉模型“这块是干嘛的“,在多 Skill 共存时帮模型更准确地用上正确那块。

多个 Skill 按需挂载

Skill 多了之后,不要全挂 —— 每挂一个就是几百 token 的常驻 system 内容。按用户意图挑:

// src/agent.ts(更精细的版本)
configureSession(session: Session) {
  // 默认挂 code-review,因为这是 CoderAgent 主战场
  return session
    .withContext("code-review-skill", SKILLS["code-review"])
    .withCachedPrompt();
}

// 在 beforeTurn 钩子里按 user 意图动态加挂
async beforeTurn(ctx) {
  const lastUserText = ctx.messages.at(-1)?.parts
    ?.find(p => p.type === "text")?.text ?? "";
  if (/重构|refactor/i.test(lastUserText) && SKILLS["refactor-plan"]) {
    ctx.session = ctx.session.withContext(
      "refactor-skill",
      SKILLS["refactor-plan"],
    );
  }
}

beforeTurn 是 Think 的每轮入口钩子(think.d.ts:490),在 LLM 看到消息之前给你一次改 session 的机会。

第二部分:Agent Memory(私测)

以下基于 Agent Memory 公告 的 binding 形态。2026-04-30 实测确认 Memory binding 仍未对所有账号开放,本节代码无法在普通账号上跑通,字段名也以 waitlist 释出后的官方 d.ts 为准 —— 本节当架构参考读,生产前请重新校对实际签名。

binding

// wrangler.jsonc(增量,Memory 申请通过后)
{
  // ... 前面字段不变
  "memory": [
    { "binding": "MEMORY" }
  ]
}
// 在 src/agent.ts 里 —— Env 加上 MEMORY(片段)
export type Env = {
  AI: Ai;
  CODER_AGENT: DurableObjectNamespace<CoderAgent>;
  LOADER: WorkerLoader;
  MEMORY: AgentMemory; // 由 wrangler types 生成
};

抽取 — 在 onChatResponse 里 ingest

每轮 LLM 回复完成后,把这段对话喂给 Memory 让它自动抽事实:

// src/agent.ts(增量)
import type { ChatResponseResult } from "@cloudflare/think";

export class CoderAgent extends Think<Env> {
  // ... 前面方法不变

  // Memory profile 名 = userId(跨会话共享)
  private profileName(): string {
    // 从 DO name 解析 user;约定 name = "<userId>:<conversationId>"
    const [userId] = this.name.split(":");
    return userId || "anonymous";
  }

  async onChatResponse(result: ChatResponseResult) {
    if (!this.env.MEMORY) return; // 私测期间可能未配置
    const profile = await this.env.MEMORY.getProfile(this.profileName());
    // 把这一轮的消息(user + assistant)交给 Memory
    // ingest 是异步抽取 —— 走 LLM 把"事实"摘出来存
    await profile.ingest(result.messages, {
      sessionId: this.name,
    });
  }
}

这里有几个要点:

  • profile 名 = userId:同一个 user 跨多个 conversation 会共享 memory。如果你想“每个项目独立 memory“,就用 ${userId}:${projectId}
  • 跨用户共享:多个不同的 name 共用同一个 profile 名 = 共享同一份 memory(blog 没明说,行为以官方文档为准)。
  • ingest 不会立刻有结果:它在后台跑 Llama 4 Scout 做 extract、Nemotron 3 做 synth,把“事实“写进 profile 的 vector store。下一轮才能召回。

召回 — 在 beforeTurn 里 recall + 塞 context

// src/agent.ts(增量)
import type { TurnContext, TurnConfig } from "@cloudflare/think";

export class CoderAgent extends Think<Env> {
  // ... 前面方法不变

  async beforeTurn(ctx: TurnContext): Promise<TurnConfig | void> {
    if (!this.env.MEMORY) return;
    const lastUserText = ctx.messages.at(-1)?.parts
      ?.find((p: any) => p.type === "text")?.text ?? "";
    if (!lastUserText) return;

    const profile = await this.env.MEMORY.getProfile(this.profileName());
    // recall 走自然语言查询,返回匹配的事实摘要
    const ans = await profile.recall(lastUserText);
    if (!ans?.result) return;

    // 把召回结果作为这一轮的 context block 临时挂上
    return {
      session: ctx.session.withContext("user-memory", {
        description: "关于这个用户的已知偏好与上下文事实",
        content: ans.result,
      }),
    };
  }
}

profile.recall(query) 返回 { result: string }(也可能带 sources,以 d.ts 为准)。它是自然语言总结,不是 raw 记忆条目 —— 直接塞 context 就行,不用再 prompt 整理。

显式存 / 列 / 删

除了 ingest 自动抽,还有手动 API:

// 显式记一条事实(用户在 UI 上点"记住这个")
await profile.remember({
  content: "用户的 GitHub username 是 easychen",
  sessionId: this.name,
});

// 列出所有 memory(给用户看"agent 记得我什么")
const all = await profile.list();

// 删除一条(用户点"忘掉这个")
await profile.forget(memoryId);

私测的话怎么办

binding 没下来之前,加一层 fallback 让代码先跑起来:

// 在 src/agent.ts 里 —— defensive(片段)
async beforeTurn(ctx: TurnContext) {
  if (!this.env.MEMORY) {
    // 私测期间:用本地 SQLite 模拟一个 "fake memory"
    return; // 或者从 this.sql 查个简单 KV 表
  }
  // ... 走真 Memory
}

申请 waitlist:blog.cloudflare.com/introducing-agent-memory 文末。

验证

Skill 生效

启动 npx wrangler dev,curl:

# Terminal
curl -N -X POST http://localhost:8787/api/chat \
  -H 'content-type: application/json' \
  -d '{"messages":[{"role":"user","parts":[{"type":"text","text":"帮我 review 一下 src/agent.ts"}]}]}'

回复应该按 Skill 里规定的 ## Critical / ## Suggestions 两段格式输出,而不是随意发散。如果还是散的,检查两件事:

  1. wrangler.jsoncrules 是否生效(看 build log 有 Compiled module: src/skills/code-review.md)。
  2. configureSession 是否真的被调用(在方法里临时 console.log("session configured"))。

Memory 生效

第一轮:

curl -N -X POST http://localhost:8787/agents/coder-agent/easychen:thread-1 \
  -H 'content-type: application/json' \
  -d '{"messages":[{"role":"user","parts":[{"type":"text","text":"我用 npm,不用 pnpm 也不用 yarn。"}]}]}'

agent 回 OK。

换一个会话(thread-2,同 user):

curl -N -X POST http://localhost:8787/agents/coder-agent/easychen:thread-2 \
  -H 'content-type: application/json' \
  -d '{"messages":[{"role":"user","parts":[{"type":"text","text":"给我生成一个安装步骤。"}]}]}'

回复里应该自动用 npm install,而不是问你“用哪个包管理器“。如果没有,说明 ingest 还在后台跑(等 5-10 秒再试),或者 recall(query) 没匹到 —— 把召回的 ans.result log 出来看看。

边界与坑

  • Skill 不是越多越好。每挂一个 context block,就多几百 token 常驻 system。多 Skill 时优先按用户意图按需挂,而不是全挂。
  • withCachedPrompt() 必开。Skill 内容稳定,正适合 prompt caching。不开等于每轮都全文重算。
  • Memory 是私测。没下来之前,代码用 if (!this.env.MEMORY) return 做软降级,不要让缺 binding 把整个 agent 弄崩。
  • profile 名是 PII 边界。把 userId 当 profile name 是最简形式;如果你的产品里“项目“是更自然的隔离单元,就用 ${userId}:${projectId}不要全用 "global" 一个 profile —— 跨用户串记忆会出大问题。
  • recall 有 latency。它在后台跑向量检索 + LLM 总结,每次大约 200-500ms。beforeTurn 里调它,等于每轮加这点延迟 —— 可以接受,但别再往里塞别的同步 LLM 调用了。
  • Skill 与 Memory 之间不交叉。Skill 是方法,Memory 是事实。不要把 “用户偏好” 写进 Skill,也不要把“做事步骤“塞进 Memory 让它记。两者搞混会让两边都失效。

延伸阅读

下一章预告

到这里,agent 已经有“手“(工具)、“脑”(Code Mode)、“方法”(Skill)、“记忆”(Memory)。但所有动作都还局限在 Worker / DO 内 —— 它不能真的跑 npm install、不能起 dev server、不能克隆仓库改文件。下一章我们引入 Cloudflare Sandboxes(2026-04 GA),给 agent 一个真 Linux 环境:shell、文件系统、长进程、PTY、port 暴露,一应俱全。