第 3 章:树形会话 —— Session API
一句话定位:让对话从“一条直线“变成“一棵树“,任意一点都能 fork 重试、过长可压缩、过往可全文搜索;再加一条:每个用户每个会话都是一个独立 Durable Object。
想要什么
第 2 章能切 provider 了。继续聊几轮,新痛点冒出来:
- 用户问“用 React 写个 todo“,agent 给了一坨。用户说“换 Vue 重写“—— 旧的 React 答案被新对话覆盖,想回头比对?没了。
- 同一会话聊到第 200 轮,history 塞满 200K token,推理变慢账单暴涨 —— 但前 150 轮早就用不上。
- 用户隔了一周回来:“上次我们讨论的那个部署窗口是周几?” agent 茫然 —— 它不知道怎么搜自己的历史。
- 两个用户共用一个
CoderAgent,session 串了:A 看见 B 的 todo。
要补的能力:树形 fork(任意一条消息拉新分支,原分支不动)、compact(老消息压成摘要)、search(LLM 自己搜过往,FTS5)、多用户隔离(userId:conversationId 当 DO 名)、context block(user profile 这种“始终该看见“的内容挂在系统提示固定区,不混进消息流)。
这五件事在传统“消息数组 + system prompt 拼接“架构里全部要自己写。Project Think 的 Session API 把它们做成了 configureSession() 钩子里的链式调用 —— 这就是本章主角。
为什么
上一代 AIChatAgent 用的是平铺消息列表:this.messages 是一个 UIMessage[],你 append、截断、序列化。所有“分支““压缩”“检索“都得自己写,结果就是每个项目都在重新发明一次浅薄版本的 git。
Project Think 的 Session API 借鉴了 git 和 Pi.dev 的设计:消息是树。每条消息有 parent_id,appendMessage 默认挂到最新叶子下,但你可以指定任意 parent_id 形成分支。getHistory(leafId) 走根到指定叶子的“线性化“路径喂给 LLM —— LLM 看不到树的存在,但你可以。
底下的存储是 Durable Object SQLite,不需要外部数据库或 vector store,FTS5 索引和压缩历史都在同一个 DO 里。配 token 阈值 + 一个 summarize 函数就自动 compact;配可写 context block 模型就自己学会调 set_context 工具。
图 3-1:树形会话的样子
方案选择
| 方案 | 适合什么 | 我们用吗 |
|---|---|---|
自己用 this.sql 写消息表 + 手写 fork / compact / FTS5 | 需要完全自定义 schema | 不用,3 个月才写得稳 |
上一代 AIChatAgent 的 this.messages 平铺数组 | 简单 chat,不需要分支 | 不用,树是 v2 的核心 |
Think 的 configureSession() + agents/experimental/memory/session | 标准多轮 agent,要分支 / 压缩 / 搜索 | 用 |
| 外接 LangGraph + Postgres | 已有 Python 栈、不在 Cloudflare 上 | 不用 |
Session API 在
agents/experimental/memory/session下,实验性(2026-04 状态)。Think 已经把它当默认存储,API 表面稳定,字段名可能微调 —— 锁版本agents@^0.11。
落地
这一章动三处:src/agent.ts(改 configureSession + 加 chat() 钩子)、src/index.ts(按 userId 路由)、客户端示例(显示分支)。
1. configureSession + Session-aware /api/chat
ch02 的 /api/chat 只是把客户端 messages 数组转给 LLM,不持久化。ch03 的版本改成“客户端只发最后一条用户消息,历史从 Session(SQLite)读、回答 stream 完后写回“,这样不同 DO 实例就有了真正独立、可记忆、可分支的对话。
// src/agent.ts
import { Think } from "@cloudflare/think";
import { createAiGateway } from "ai-gateway-provider";
import { createUnified } from "ai-gateway-provider/providers/unified";
import { callable } from "agents";
import {
generateText,
streamText,
convertToModelMessages,
type LanguageModel,
type UIMessage,
} from "ai";
import type { Session, SessionMessage } from "agents/experimental/memory/session";
import { createCompactFunction } from "agents/experimental/memory/utils";
export type AIProvider = "workers-ai" | "anthropic" | "openai" | "google";
export type Env = Omit<Cloudflare.Env, "AI_PROVIDER"> & {
AI_PROVIDER?: AIProvider;
AI_GATEWAY_ID?: string;
CF_ACCOUNT_ID?: string;
CF_AIG_TOKEN?: string;
};
const MODEL_MAP: Record<AIProvider, string> = {
"workers-ai": "workers-ai/@cf/meta/llama-3.3-70b-instruct-fp8-fast",
"anthropic": "anthropic/claude-haiku-4-5",
"openai": "openai/gpt-5-mini",
"google": "google-ai-studio/gemini-2.5-pro",
};
export class CoderAgent extends Think<Env> {
getModel(): LanguageModel {
const provider = this.env.AI_PROVIDER ?? "workers-ai";
const aigateway = createAiGateway({
accountId: this.env.CF_ACCOUNT_ID!,
gateway: this.env.AI_GATEWAY_ID ?? "default",
apiKey: this.env.CF_AIG_TOKEN,
});
const unified = createUnified();
return aigateway(unified(MODEL_MAP[provider]));
}
configureSession(session: Session) {
return session
// soul:固定身份,始终在系统提示顶部
.withContext("soul", {
provider: { get: async () => "你是 agent-coder,中文编码助手。回答简洁,优先给可运行示例。" },
})
// memory:AI 可写的 fact 表(模型自动拿到 set_context 工具)
.withContext("memory", {
description: "关于这位用户和当前项目的事实",
maxTokens: 2000,
})
// notes:AI 可写 + FTS5 可搜(模型自动拿到 search_context 工具)
.withContext("notes", {
description: "本对话沉淀的设计决策、未决问题、待办",
maxTokens: 4000,
})
.withCachedPrompt() // 系统提示冻结落盘,DO 休眠重启不重算
.onCompaction( // 老消息压成摘要,原文仍留 SQLite
createCompactFunction({
summarize: async (prompt: string) =>
(await generateText({ model: this.getModel(), prompt })).text,
protectHead: 4,
tailTokenBudget: 20000,
minTailMessages: 6,
}),
)
.compactAfter(60_000); // 触发阈值:60K token
}
// /api/chat:用 Session 真持久化每轮对话
// 1. 客户端只发"最后一条用户消息",历史从 SQLite 读
// 2. assistant 回答 stream 完后也写回 Session
async onRequest(request: Request): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === "/api/chat" && request.method === "POST") {
const { messages } = await request.json<{ messages: UIMessage[] }>();
const lastUser = messages[messages.length - 1];
// 1. 把新的用户消息挂到当前 leaf 后面(默认 parentId=getLatestLeaf)
await this.session.appendMessage({
id: crypto.randomUUID(),
role: "user",
parts: lastUser?.parts ?? [],
} as SessionMessage);
// 2. 用 SQLite 里的完整历史调 LLM
const history = this.session.getHistory();
const result = streamText({
model: this.getModel(),
system: "你是 agent-coder,中文编码助手。",
messages: await convertToModelMessages(history as unknown as UIMessage[]),
onFinish: async ({ text }) => {
// 3. assistant 回答写回 Session,作为新的 leaf
await this.session.appendMessage({
id: crypto.randomUUID(),
role: "assistant",
parts: [{ type: "text", text }],
} as SessionMessage);
},
onError: ({ error }) => console.error("streamText:", error),
});
return result.toTextStreamResponse();
}
return super.onRequest(request);
}
// —— 自定义 RPC —— 客户端用 agent.call("forkFrom", [...]) 调
@callable()
async forkFrom(parentId: string, userText: string) {
// 在 parentId 下面挂一条新的 user 消息,形成新分支
await this.session.appendMessage(
{
id: crypto.randomUUID(),
role: "user",
parts: [{ type: "text", text: userText }],
} as SessionMessage,
parentId,
);
// 触发一轮:Think 内部会调 LLM 生成回答,挂到刚才那条 user 消息下
await this.continueLastTurn();
return { branchLeaf: this.session.getLatestLeaf()?.id };
}
@callable()
async searchMemory(query: string) {
return this.session.search(query, { limit: 10 });
}
@callable()
async listBranches(messageId: string) {
return this.session.getBranches(messageId);
}
@callable()
async switchToBranch(leafId: string) {
// 返回从根到指定 leaf 的线性化历史,客户端可以直接渲染
return this.session.getHistory(leafId);
}
}
读这段代码的几个要点(实测踩出来的):
configureSession只在 DO 第一次启动时跑一次,不是每轮跑。它配置的是 session 的“骨架“。createCompactFunction的真实导出路径是agents/experimental/memory/utils,不是agents/experimental/memory/utils/compaction-helpers(后者是内部 chunked d.ts 文件名,直接 import 会 TS2307)。SessionMessage类型从agents/experimental/memory/session导出,不在utils里 —— 容易踩TS2459: declares 'SessionMessage' locally, but it is not exported。summarize回调的prompt参数要显式声明类型(prompt: string),否则在 strict 模式 tsc 报 implicit any。- 加了 context block 后,Think 不再用
getSystemPrompt()—— 系统提示由 block 拼出来,第 2 章那个getSystemPrompt可以删,内容挪到soulblock。 memory和notes块都是 AI 可写的:Think 会自动给 LLM 暴露set_context工具,模型决定“这事实值得记“就写进去,不用你写记忆抽取代码。notes没指定 provider,Think 默认挂AgentSearchProvider(SQLite FTS5),模型还会拿到search_context工具自己搜过往笔记。@callable()注册的方法直接透过 DO boundary 暴露给客户端agent.call("forkFrom", [...]),但只走 WebSocket —— 想用 curl 测得自己写一个 HTTP 调试端点,或用agents/client的 AgentClient。
2. 多用户隔离:DO name = userId:conversationId
每个 DO 实例有独立的 SQLite,所以 DO 实例名就是租户隔离边界。我们在入口处把 user id 跟 conversation id 拼起来,getAgentByName 拿到对应实例:
// src/index.ts
import { routeAgentRequest, getAgentByName } from "agents";
import type { CoderAgent, Env } from "./agent";
export { CoderAgent } from "./agent";
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
// /api/chat?user=u123&conversation=c456 —— 路由到 u123:c456 这个 DO
if (url.pathname === "/api/chat") {
const userId = url.searchParams.get("user") ?? "anon";
const conversationId = url.searchParams.get("conversation") ?? "default";
const name = `${userId}:${conversationId}`;
const agent = await getAgentByName<Env, CoderAgent>(env.CODER_AGENT, name);
return agent.fetch(request);
}
// /agents/coder-agent/{name} 仍走默认 routing(WebSocket、@callable RPC 都走这条)
return (
(await routeAgentRequest(request, env)) ??
new Response("Not found", { status: 404 })
);
},
};
真实生产:
userId应该来自 Cloudflare Access JWT 或你自己的 session cookie,不能让客户端随便传 —— 第 10 章会补鉴权。
3. 客户端:显示分支的简单 dropdown
useAgentChat 透明处理消息流,但分支信息得自己问。useAgent 暴露了 agent.call(method, args) 直接调上面 @callable 注册的方法:
// public/chat.tsx —— 关键片段,完整 React app 略
import { useAgent } from "agents/react";
import { useAgentChat } from "@cloudflare/ai-chat/react";
import { useState } from "react";
export function Chat({ userId, conversationId }: { userId: string; conversationId: string }) {
const agent = useAgent({ agent: "CoderAgent", name: `${userId}:${conversationId}` });
const chat = useAgentChat({ agent });
const [branches, setBranches] = useState<Array<{ id: string; preview: string }>>([]);
return (
<div>
{chat.messages.map((m) => (
<div key={m.id}>
<strong>{m.role}:</strong> {m.parts?.[0]?.text}
{m.role === "user" && (
<button onClick={async () => {
const result = await agent.call("listBranches", [m.id]);
setBranches(result.map((b: any) => ({
id: b.id,
preview: (b.parts?.[0]?.text ?? "").slice(0, 40),
})));
}}>查看分支</button>
)}
</div>
))}
{branches.length > 0 && (
<select onChange={async (e) => { await agent.call("switchToBranch", [e.target.value]); }}>
<option>选择分支...</option>
{branches.map((b) => <option key={b.id} value={b.id}>{b.preview}</option>)}
</select>
)}
</div>
);
}
switchToBranch 调完后,useAgentChat 会通过 WebSocket 收到 history 更新事件自动重渲;不需要手动 setState。
不打算写 React 也没关系,下面的验证用 curl 就能跑通整条路径。
验证
跟 ch01/ch02 一样,部署到边缘 curl 是最稳路径。
# Terminal
ACC=<你的 account id>
TOKEN=cfut_xxx # ch02 那个 AI Gateway auth token
CLOUDFLARE_ACCOUNT_ID=$ACC npx wrangler deploy \
--var AI_PROVIDER:anthropic \
--var "CF_ACCOUNT_ID:$ACC" \
--var "CF_AIG_TOKEN:$TOKEN"
Step 1:多用户路由 + Session 持久化(本章核心)
# Terminal — 同一个 DO(easy:test1)发两条消息,验证记忆
curl -N -X POST 'https://agent-coder.<sub>.workers.dev/api/chat?user=easy&conversation=test1' \
-H 'content-type: application/json' \
-d '{"messages":[{"role":"user","parts":[{"type":"text","text":"我叫 Easy,在做 Cloudflare Agents 教程。记住这个上下文。"}]}]}'
# → "记下了,你叫 Easy..."
curl -N -X POST 'https://agent-coder.<sub>.workers.dev/api/chat?user=easy&conversation=test1' \
-H 'content-type: application/json' \
-d '{"messages":[{"role":"user","parts":[{"type":"text","text":"我刚跟你说我叫什么?在做什么?"}]}]}'
# → "你说你叫 Easy,正在做 Cloudflare Agents 教程..." ← Session 真持久化的证据
第二条消息只发了“我刚跟你说我叫什么?“这一句,客户端没有重发第一句历史 —— Llama/Claude 仍然能正确回答,因为我们的 onRequest 用 this.session.getHistory() 从 SQLite 拿到完整历史。
# Terminal — 不同的 user / conversation 是不同的 DO,记忆不串
curl -N -X POST 'https://agent-coder.<sub>.workers.dev/api/chat?user=bob&conversation=test1' \
-H 'content-type: application/json' \
-d '{"messages":[{"role":"user","parts":[{"type":"text","text":"我刚跟你说我叫什么?"}]}]}'
# → "我们刚见面,你还没告诉我你的名字..." ← 不串
Step 2:fork + search + compact(可选,需要 WebSocket)
@callable() 装饰的方法 forkFrom / searchMemory / listBranches / switchToBranch 都通过 WebSocket RPC 暴露,curl 直接打不到。两条路验:
- 客户端
useAgent—— 看上面那段 React 示例,agent.call("forkFrom", [...])一行搞定。这是生产里的正常路径。 - 裸 WebSocket(
websocat或wscat):连到wss://你的域名/agents/coder-agent/easy:test1,发 RPC 帧:{"type":"rpc","id":"1","method":"searchMemory","args":["教程"]}。
或者最简单 —— 加一个 HTTP 调试端点直接调底层 Session 方法,本章末尾“边界与坑“里有示例。
Step 3:验证 compact 触发(可选)
把 compactAfter(60_000) 临时改成 compactAfter(2_000),多聊几轮触发压缩。wrangler tail 会出现 [session] compaction triggered, summarized N messages。下一轮 LLM 看到的系统提示前面多一段 [Compacted summary of N earlier messages: ...],token 总数立刻下来 —— 原始消息仍在 SQLite,FTS5 搜索还能搜到。
边界与坑
appendMessage是 async(可能触发 compaction),其它写方法updateMessage/deleteMessages/clearMessages是同步的。测试里别漏await。addContext/removeContext不会自动刷新冻结的系统提示,改完调session.refreshSystemPrompt()。withCachedPrompt()模式尤其要注意。- FTS5 是消息全文索引,不是语义搜索。“部署最佳实践“搜不到“如何上线” —— 第 5 章引入 Agent Memory 加语义层。
- DO 名字是隔离边界,不是鉴权边界:能拼出
alice:todo的人都能访问那个 DO。第 10 章用 Cloudflare Access JWT 在onConnect校验。
延伸阅读
- Sessions API 完整文档(中译) —— 所有 builder 方法、provider 类型、内置工具
- Project Think:Session 集成 ——
configureSession钩子在 Think 生命周期里的位置 - Project Think 公告(中译) —— Cloudflare 解释为什么把会话做成树
- Routing & getAgentByName —— 多用户多会话的命名约定与边界
下一章预告
会话能记、能搜、能分叉了,但 agent 本身还只会“说话“—— 它没法读你的文件、改你的代码、跑命令。下一章我们引入工具:用 createWorkspaceTools 把文件系统暴露给 LLM,再用 Code Mode(createExecuteTool)让模型一次写一段 JS 把多个工具串起来跑,而不是一次次往返。同样的“找出 src/ 下所有 console.log“任务,Code Mode 一次完成,传统 tool calling 要 20 次。