第 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:三类持久化的边界
方案选择
| 存什么 | 用什么 | 为什么 |
|---|---|---|
| 源码、配置、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 types 让 worker-configuration.d.ts 把 env.BUCKET 和 env.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 写一段编排脚本一次跑完多个工具,出问题还能从中间步骤恢复。