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

第 7 章:留得下 — Artifacts + R2 + Agent Memory

一句话定位:你会让 agent 把每个会话的代码版本化、把大文件存好、把跨会话的事实记住,sandbox 睡了、用户换浏览器了,东西都还在。

想要什么

第 6 章给 agent 配了一台机器,你可以让它 git clone 一个项目、改几个文件、跑个测试。问题是:改完之后呢?

  • sandbox sleepAfter 默认 10 分钟。idle 一过,/workspace 全没了。
  • 用户回来想看“昨天 agent 帮我改了哪几行“,对话历史里只有一段 tool-result: ok,改的是哪个版本说不清。
  • agent 自己跑了一次 vitepress build,生成了几个 MB 的 HTML/CSS。下次 LLM 想引用的话,只能让它重跑一遍 build —— 时间和钱都浪费。
  • 用户问“上周我让你 review 的那个 PR,我们当时讨论的最大问题是什么?“。session 历史早就 compaction 了,事实点丢了。

我们要的是三种“留得下“:版本化的代码(可以 diff、可以回滚)、blob(图片、build artifact、压缩包)、事实(自然语言可检索的长期记忆)。一个解决不了所有问题,得分清场景

为什么

把 agent 的所有产物全塞 R2 的 prefix 里能不能用?能。但你会马上掉进 Git 早就解决的几个问题:diff 不出来、想要“过去某一刻的全状态“得自己组装、想给同事 fork 一份接着干没有现成手段、想做“agent 改了哪些文件“的 review 得自己写 walker。

把所有事都塞 D1 / SQLite 也不行 —— 二进制大文件本来就不该进数据库;一份 dataset 就把表撑爆了。

Cloudflare 在 Agents Week 2026 把这三件事各自给了一个原语:Artifacts(beta,Git for agents)管版本化文本;R2 管 blob;Agent Memory(私测)管语义事实。我们一章用清三个,顺便讲清每一个的边界。

图 7-1:三类持久化的边界

Artifacts、R2、Agent Memory 各管一段 该把东西放到哪里 Artifacts git for agents 放:源码 / 配置 / Markdown 放:小文本 / agent 改动 能:diff / 回滚 / fork 能:任何 git 客户端连 不放:大二进制 不放:语义查询源 R2 object storage 放:build 产物 / 截图 放:数据集 / PDF / 视频 能:零出口费 能:直发 presigned URL 不放:要 diff 的文本 不放:语义查询源 Agent Memory semantic recall 放:跨会话事实 放:用户偏好 / 决策 能:自然语言 recall 能:profile 隔离 不放:精确文件内容 不放:大于几 KB 的块

方案选择

存什么用什么为什么
源码、配置、agent 改动的小文本Artifacts(本章)自带版本、能 fork、能用普通 git 客户端连
build 产物、用户上传、大二进制R2(本章)零出口费,直接发可下载链接
“用户喜欢什么 package manager”Agent Memory(本章简短)语义检索,不依赖关键词命中
短小的运行时配置(feature flag、个人化设置)KV(不在本章)毫秒级读、最终一致
跨会话的关系表(订单、issue 的状态机)D1(不在本章)标准 SQL、跨实例查询

Artifacts 在 2026-04 是 beta(blog artifacts-git-for-agents-beta),没有 npm 包,只有 binding。Agent Memory 是 private beta,本章只展示形态。

落地

增量装包

Artifacts 没有 npm 包,R2 / Memory 也是 binding,这一章不装新依赖。但要先在 Cloudflare dashboard 创建一个 R2 bucket(取名 agent-coder-blobs)和一个 Artifacts namespace(取名 default)。

wrangler.jsonc 增量

// wrangler.jsonc(在 Ch6 基础上加)
{
  "r2_buckets": [
    { "binding": "BUCKET", "bucket_name": "agent-coder-blobs" }
  ],
  "artifacts": [
    { "binding": "ARTIFACTS", "namespace": "default" }
  ]
  // memory binding 形态待 GA 公布,现在跳过
}

跑一次 npx wrangler typesworker-configuration.d.tsenv.BUCKETenv.ARTIFACTS 的类型补齐。

主题 1:Artifacts —— 给每个会话一个 git 远端

核心动作只有三步:create → 在 sandbox 里 clone → push 回去

// src/tools/artifact.ts
import { tool } from "ai";
import { z } from "zod";
import { getSandbox } from "@cloudflare/sandbox";
import type { Env } from "../agent";

// SQL 句柄类型 —— Think/Server 上的 sql 是 tagged template,
// 用类型别名包一下,工厂里只需要"能跑 SQL"
type SqlFn = <T = Record<string, string | number | boolean | null>>(
  strings: TemplateStringsArray,
  ...values: (string | number | boolean | null)[]
) => T[];

// 一个会话一个 repo,key 写在 agent 的 SQL 里复用
async function ensureRepo(env: Env, sessionId: string, sql: SqlFn) {
  const cached = sql<{ repo_name: string }>`
    SELECT repo_name FROM artifact_state WHERE conversation_id = ${sessionId}
  `[0];
  if (cached) return await env.ARTIFACTS.get(cached.repo_name);

  const name = `conv-${sessionId}`;
  const repo = await env.ARTIFACTS.create(name, {
    description: `Workspace for conversation ${sessionId}`,
  });
  sql`
    CREATE TABLE IF NOT EXISTS artifact_state (
      conversation_id TEXT PRIMARY KEY, repo_name TEXT NOT NULL
    )
  `;
  sql`INSERT INTO artifact_state VALUES (${sessionId}, ${name})`;
  return repo; // { name, remote, token }
}

// Agent.env 是 protected,工厂得显式吃 (env, sessionId, sql)
export function createArtifactTools(env: Env, sessionId: string, sql: SqlFn) {
  const sb = getSandbox(env.Sandbox, sessionId);

  return {
    initArtifact: tool({
      description: "把当前 sandbox 的 /workspace 初始化为 Artifacts 仓库的 working tree",
      inputSchema: z.object({}),
      execute: async () => {
        const repo = await ensureRepo(env, sessionId, sql);
        // token 走 URL 内嵌,sandbox 里的 git 子进程读不到 raw env
        const cloneUrl = repo.remote.replace("https://", `https://x:${repo.token}@`);
        // sandbox.exec 现在只接受 (command: string, options?) —— 把 args 拼回字符串
        await sb.exec("git init", { cwd: "/workspace" });
        await sb.exec(`git remote add origin ${cloneUrl}`, { cwd: "/workspace" });
        return { remote: repo.remote, name: repo.name };
      },
    }),

    commitToArtifact: tool({
      description: "把 sandbox 里 /workspace 的全部改动 commit + push 回 Artifacts",
      inputSchema: z.object({
        message: z.string().describe("commit message,要写人能看懂的"),
      }),
      execute: async ({ message }) => {
        await sb.exec("git add -A", { cwd: "/workspace" });
        // 用 -c 注入 identity,引号转义放到字符串里
        await sb.exec(
          `git -c user.email=agent@local -c user.name=Agent commit -m ${JSON.stringify(message)}`,
          { cwd: "/workspace" },
        );
        const r = await sb.exec("git push origin HEAD:main", { cwd: "/workspace" });
        return { pushed: r.exitCode === 0, stderr: r.stderr.slice(0, 500) };
      },
    }),
  };
}

几个值得拆开看的细节:

  • env.ARTIFACTS.create(name) 返回 { name, remote, token }remote 是 HTTPS URL,token 是带 ?expires=... 的短期凭证 —— 我们把它内嵌到 URL 里(https://x:${token}@...)给 git 用,过期就重新调 repo.createToken() 取一个。
  • 如果你担心 token 落进 sandbox 的 shell 历史,改用 git -c http.extraHeader="Authorization: Bearer $TOKEN" 形式,在 outboundByHost 里给 *.artifacts.cloudflare.net 注入 header,跟第 6 章 outboundByHost 一个套路 —— 这样 token 完全留在 Worker 一侧。
  • 仓库名字一会话一份(conv-<agent.name>),保证 fork、回滚、对比都是会话粒度。

主题 2:R2 —— 大 blob 走对象存储

commitToArtifact 不要塞 build 产物 —— git 不擅长大二进制,Artifacts 也对单 object 有大小约束。build dist 应该走 R2:

// src/tools/save-blob.ts
import { tool } from "ai";
import { z } from "zod";
import { getSandbox } from "@cloudflare/sandbox";
import type { Env } from "../agent";

type SqlFn = <T = Record<string, string | number | boolean | null>>(
  strings: TemplateStringsArray,
  ...values: (string | number | boolean | null)[]
) => T[];

// Agent.env 是 protected,工厂得显式吃 (env, sessionId, sql)
export function createBlobTools(env: Env, sessionId: string, sql: SqlFn) {
  const sb = getSandbox(env.Sandbox, sessionId);

  return {
    saveBlob: tool({
      description: "把 sandbox 里某个文件 PUT 到 R2,返回可下载 URL",
      inputSchema: z.object({
        srcPath: z.string().describe("sandbox 内的绝对路径"),
        contentType: z.string().default("application/octet-stream"),
      }),
      execute: async ({ srcPath, contentType }) => {
        // ⚠️ R2.put 给 ReadableStream 时要求 Content-Length,sandbox.readFileStream
        // 不带长度;改用 readFile 拿完整 buffer/string,R2 自己算长度
        const file = await sb.readFile(srcPath);
        const content = (file as any).content ?? file;
        const key = `${sessionId}/${Date.now()}-${srcPath.split("/").pop()}`;
        await env.BUCKET.put(key, content, { httpMetadata: { contentType } });

        sql`
          CREATE TABLE IF NOT EXISTS blobs (
            key TEXT PRIMARY KEY, src_path TEXT, content_type TEXT, created_at INTEGER
          )
        `;
        sql`
          INSERT INTO blobs VALUES (${key}, ${srcPath}, ${contentType}, ${Date.now()})
        `;

        // 让 worker 的 /blobs/* 路由代理回 R2
        return { url: `/blobs/${key}`, key };
      },
    }),
  };
}

worker 入口加一段简单代理(放在 routeAgentRequest 之前):

// src/index.ts(节选)
if (request.url.includes("/blobs/")) {
  const key = new URL(request.url).pathname.replace("/blobs/", "");
  const obj = await env.BUCKET.get(key);
  if (!obj) return new Response("not found", { status: 404 });
  return new Response(obj.body, {
    headers: { "content-type": obj.httpMetadata?.contentType ?? "application/octet-stream" },
  });
}

R2 没有出口费,这条 URL 发给用户随便下,不烧钱。

把工具挂到 agent(完整文件)

agent.env 是 protected,工厂得在类内部把 (this.env, this.name, this.sql.bind(this)) 显式传进去。顺便给 CoderAgent 加一个 commitToArtifact(...) 方法 —— Workflow / sub-agent 直接 RPC 调它,不用再走 LLM tool 那一层。

// src/agent.ts
import { Think } from "@cloudflare/think";
import { createWorkersAI } from "workers-ai-provider";
import { buildExecuteTool } from "./tools/execute";
import { getCurrentTime, getWeather } from "./tools/index";
import { createShellTools } from "./tools/shell";
import { createArtifactTools } from "./tools/artifact";
import { createBlobTools } from "./tools/save-blob";
import type { CoderSandbox } from "./sandbox";

export type Env = {
  AI: Ai;
  CODER_AGENT: DurableObjectNamespace<CoderAgent>;
  LOADER: WorkerLoader;
  Sandbox: DurableObjectNamespace<CoderSandbox>;
  BUCKET: R2Bucket;
  ARTIFACTS: ArtifactsBinding;
};

// Artifacts binding 还在 beta,@cloudflare/workers-types 暂未带上类型;
// 这里给一份最小够用的 shape,GA 后由 wrangler types 自动补齐
export interface ArtifactsBinding {
  get(name: string): Promise<ArtifactsRepo | null>;
  create(name: string, options?: { description?: string }): Promise<ArtifactsRepo>;
}
export interface ArtifactsRepo {
  name: string;
  remote: string;
  token: string;
}

export class CoderAgent extends Think<Env> {
  getModel() {
    const wai = createWorkersAI({ binding: this.env.AI });
    return wai("@cf/moonshotai/kimi-k2.5");
  }

  getSystemPrompt() {
    return [
      "你是一个编程助手。",
      "对于多步、组合性的任务优先调 execute,在沙盒里写一段 JS 完成。",
      "改完代码记得用 commitToArtifact 把工作树落到 Artifacts。",
    ].join("\n");
  }

  getTools() {
    return {
      execute: buildExecuteTool(this.workspace, this.env.LOADER),
      getCurrentTime,
      getWeather,
      ...createShellTools(this.env, this.name),
      ...createArtifactTools(this.env, this.name, this.sql.bind(this)),
      ...createBlobTools(this.env, this.name, this.sql.bind(this)),
    };
  }

  /**
   * 给 Workflow / sub-agent 用的 RPC 方法 —— 跟 commitToArtifact tool 干同一件事,
   * 但不用走 LLM 这条路。第 8 章会用到。
   */
  async commitToArtifact(args: {
    conversationId: string;
    branch: string;
    message: string;
  }): Promise<{ sha: string; ref: string }> {
    const { getSandbox } = await import("@cloudflare/sandbox");
    const sb = getSandbox(this.env.Sandbox, args.conversationId);
    await sb.exec("git add -A", { cwd: "/workspace" });
    await sb.exec(
      `git -c user.email=agent@local -c user.name=Agent commit -m ${JSON.stringify(args.message)}`,
      { cwd: "/workspace" },
    );
    await sb.exec(`git push origin HEAD:${args.branch}`, { cwd: "/workspace" });
    const head = await sb.exec("git rev-parse HEAD", { cwd: "/workspace" });
    return { sha: head.stdout.trim(), ref: `refs/heads/${args.branch}` };
  }
}

主题 3:Agent Memory —— 跨会话的事实层(私测)

Memory(blog introducing-agent-memory)是 binding-only 的托管服务,一个 profile 对应一个隔离的 DO + Vectorize + Workers AI。它不替代 Artifacts / R2,而是补充:Artifacts 让你“找到那个文件“,Memory 让你“想起那件事“。

形态长这样(以 blog 公布的伪 API 为准,字段 GA 时再校准):

// 在 src/tools/memory.ts 里 —— 暂不接到主线,展示形态(片段)
const profile = await agent.env.MEMORY.getProfile(`user-${userId}`);

// 在第 5 章的 "compaction 时" 钩子里调一次,把当前 session 摘事实
await profile.ingest(messages, { sessionId: agent.name });

// LLM 主动 recall:返回一句自然语言总结
const ans = await profile.recall("What package manager does this user prefer?");
// ans.result === "npm"

第 5 章我们已经讲过 Skills 怎么“按需上场“ —— Memory 是同一种思路的另一面:事实按需 recall,不挤进每轮 system prompt。但因为 Memory 在 2026-04 还是 private beta,本书的 starter 仓库不依赖它落地;真到你的 production 环境拿到 waitlist 名额,把 MEMORY binding 加进 wrangler、把上面三行接到 beforeTurn 钩子就能用。

验证

让 agent 在 sandbox 里跑 vitepress build,把 dist 回写到 Artifacts,同时把单页 HTML 的截图丢 R2,最后给用户一条预览链接。完整链路打通就算这一章成。

# Terminal
npx wscat -c ws://localhost:8787/agents/coder-agent/persistence-demo
> {"type":"cf_agent_use_chat_request","init":{"messages":[{"role":"user","parts":[{"type":"text","text":"先 initArtifact,然后 git clone https://github.com/vuejs/vitepress 到 /workspace,跑 npm install 和 npx vitepress build docs,把 docs/.vitepress/dist/index.html 用 saveBlob 存到 R2,最后 commitToArtifact 一下并把 R2 链接告诉我"}]}]}}

成功长这样(节选 SSE 流里有意义的几条):

tool-result initArtifact     → { remote: "https://....artifacts.cloudflare.net/git/conv-persistence-demo.git", name: "conv-persistence-demo" }
tool-result runShell git clone   → exitCode: 0
tool-result runShell npm install → exitCode: 0  (耗时较长)
tool-result runShell vitepress   → exitCode: 0
tool-result saveBlob              → { url: "/blobs/persistence-demo/170....-index.html", ... }
tool-result commitToArtifact      → { pushed: true }
text-final  好了,build 完成。预览页:http://localhost:8787/blobs/persistence-demo/170....-index.html;
            源码版本可以 git clone https://....artifacts.cloudflare.net/git/conv-persistence-demo.git 拿到。

打开那条 /blobs/... 链接,你应该看到 vitepress 的首页。然后用本机 git clone Artifacts URL,能看到完整的 working tree 和一条提交记录 —— 这就是 agent 写完一段代码后真正“留下来的东西“。

边界与坑

  • Artifacts token 是短期的?expires= 自带过期时间。长任务跑过几小时,记得 catch push 失败 → repo.createToken() 重发。文档没写默认 TTL,以 dashboard 显示为准。
  • 不要把大二进制 commit 进 Artifacts。Git 对大文件、二进制本来就不友好,Artifacts 的存储模型(DO SQLite + Wasm Git server)更倾向小对象。build 产物、图片、数据集走 R2。
  • R2 key 一定要带 agent.name 前缀。多个会话共用一个 bucket,key 撞了就互相覆盖。前缀也是后面“删掉这个会话所有 blob“的唯一抓手。
  • Memory 是私测 + 不要在 Skill 里写死 MEMORY binding。没拿到名额的读者会 404。封装成 if (env.MEMORY) { ... } 形态,优雅降级到 Artifacts 的 git-notes(git notes add)做“会话级事实“。
  • commit 别太碎。LLM 容易每改一行就 commit 一次,review 起来灾难。在 commitToArtifact 工具描述里强调“一次有意义的语义改动 = 一次 commit“,或者你在 beforeToolCall 里加节流。

延伸阅读

下一章预告

到这里,agent 有手有脑、能记住、有产物。下一章我们把这些拼成一个真正闭环的 coding agent:用 Workflows v2 编排“分析 issue → 写代码 → 跑测试 → commit 到 Artifacts → 起预览“的完整流程,中间用 Code Mode 让 LLM 写一段编排脚本一次跑完多个工具,出问题还能从中间步骤恢复。