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

第 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:树形会话的样子

两条分支共享前缀,getHistory(leaf) 决定喂给 LLM 的路径 Session = 消息树,不是消息列表 user: 写个 todo (m1) assistant: React (m2) +dark mode (m3a) 换 Vue (m3b, FORK) React+dark (m4a) Vue 版 (m4b) getHistory(m4b) → [m1, m2, m3b, m4b];m4a 同时存在,可随时切回

方案选择

方案适合什么我们用吗
自己用 this.sql 写消息表 + 手写 fork / compact / FTS5需要完全自定义 schema不用,3 个月才写得稳
上一代 AIChatAgentthis.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 可以删,内容挪到 soul block。
  • memorynotes 块都是 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 仍然能正确回答,因为我们的 onRequestthis.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 直接打不到。两条路验:

  1. 客户端 useAgent —— 看上面那段 React 示例,agent.call("forkFrom", [...]) 一行搞定。这是生产里的正常路径。
  2. 裸 WebSocket(websocatwscat):连到 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 校验。

延伸阅读

下一章预告

会话能记、能搜、能分叉了,但 agent 本身还只会“说话“—— 它没法读你的文件、改你的代码、跑命令。下一章我们引入工具:用 createWorkspaceTools 把文件系统暴露给 LLM,再用 Code Mode(createExecuteTool)让模型一次写一段 JS 把多个工具串起来跑,而不是一次次往返。同样的“找出 src/ 下所有 console.log“任务,Code Mode 一次完成,传统 tool calling 要 20 次。