数据截至 (上游 commit 31370a8f9b4b)
会话状态与可观测性:Chat、Session 与 Dev UI
30 秒导读: 前几章讲的
generate(03)和工具循环 (04)都是单次调用——喊一声、答一句、忘干净。本章补上 把 demo 变成产品的两条能力:① 让多轮对话记得住(Session + SessionStore 用快照持久化 消息和自定义状态);② 让开发者看得见(每个 Action 自动生成 OpenTelemetry span,汇成 trace, 再由 ReflectionServer 喂给本地 Dev UI 可视化调试)。
1. 这是什么(零基础也能懂)
一个问题起头: 你用 03 章的 ai.generate() 写了个聊天机器人。
用户问"我叫小明",模型回"你好小明";用户再问"我叫什么",模型答"我不知道"。因为每次 generate
都是全新的,上一句根本没带过来。
-
一句话定义: 「会话(Session)」= 一个把多轮消息和自定义状态跨多次
generate攒起来、并能存盘/读盘的容器;「可观测性」= 让你在浏览器里逐帧回放一次调用到底 经过了哪些 prompt、工具、模型。 -
解决什么问题 / 给谁用:
- 给做多轮 Agent 的人:不想每轮手工拼
messages数组、不想自己写数据库存对话。 - 给调 Agent 卡壳的人:模型为什么突然罢工?工具传了什么参数?肉眼看 log 太痛苦, 要一张可点开的调用树。
- 给做多轮 Agent 的人:不想每轮手工拼
-
它能做什么(功能):
- 跨多次生成累积消息、维护一坨类型安全的自定义状态(
custom,如"当前订单")。 - 把每一轮的状态存成快照(snapshot),进程重启后还能按
sessionId接着聊。 - 每个 Action(flow / prompt / tool / model 调用)自动产出一个 span,零埋点。
- 本地跑
genkit start,浏览器打开 Dev UI,列出所有 Action、点一下就能跑、看 trace。
- 跨多次生成累积消息、维护一坨类型安全的自定义状态(
-
用起来什么样: 一个最小的有状态 Agent(源码里
defineAgent的 doc 示例,js/genkit/src/genkit-beta.ts:154-162):// 示意,非源码(改编自 defineAgent 的文档示例)const chatAgent = ai.defineAgent({name: 'chatAgent',model: 'googleai/gemini-2.5-flash',system: 'Talk like a pirate.',store: new FileSessionStore('./.snapshots'), // ← 有 store 就自动存盘});const chat = chatAgent.chat(); // 开一轮新会话await chat.send('我叫小明'); // 第 1 轮:记住了await chat.send('我叫什么?'); // 第 2 轮:答"小明"——历史被带上了console.log(chat.sessionId); // 这个 id 下次可以 loadChat 接着聊 -
一句话直觉/类比: 把 Session 当"这次对话的工作内存",把 SessionStore 当"磁盘"; 每轮结束拍一张快照存进磁盘,下次开机把最新快照读回内存,对话就无缝续上。可观测性那半边, 就像给整个程序装了行车记录仪:每个动作自动录一段,事后能倒带看。
本节不碰底层。记住两个词:Session(内存里的对话状态) 和 trace(一次 调用的录像)。
2. 顶层全景(它大概怎么转)
这套东西分两个几乎正交的子系统,共享同一个底座——01 章的 Action + 注册表。先看它们怎么拼:
┌─────────────────────── 你的进程 ───────────────────────┐
│ │
用户输入 ──▶ │ AgentChat.send() │
│ │ │
│ ▼ │
│ ┌────────────┐ 每轮读→改→存 ┌──────────────┐ │
│ │ SessionRunner│◀────────────▶│ SessionStore │─┐ │
│ │ + Session │ 快照 │ (内存/文件) │ │存盘 │
│ └─────┬──────┘ └──────────────┘ │ │
│ │ runWithSession 绑定上下文 ▼ │
│ ▼ .snapshots/ │
│ ai.generate() ← getCurrentSession() 读历史 │
│ │ │
│ ┌─────┴───────────── 每个 Action 自动开 span ────────┐ │
│ │ runInNewSpan → OpenTelemetry startActiveSpan │ │
│ └─────┬─────────────────────────────────────────────┘ │
│ │ 导出 span │
└────────┼───────────────────────────────────────────────┘
▼
┌──────────────┐ /api/actions /runAction /notify ┌────────┐
│ReflectionServer│◀────────────────────────────────▶│ genkit │
│ (:3100) │ .genkit/runtimes/*.json │ CLI │
└──────┬───────┘ │ +Dev UI│
│ trace 存进 telemetry-server └────────┘
▼
/api/traces ◀── Dev UI 拉取并渲染调用树
怎么读这张图: 左半边(上)是状态子系统——对话记忆如何存盘读盘;下 半边是 可观测子系统——每个 Action 自动录 span;右边是开发者面板——CLI/Dev UI 通过 ReflectionServer 的 HTTP 接口把两者可视化。
部件一句话职责:
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
Session | 内存里持有本次对话的 messages / custom / artifacts | js/ai/src/session.ts:152 |
SessionStore | 快照的持久化接口(存/读) | js/ai/src/session.ts:99 |
InMemory / FileSessionStore | 两个内置实现:内存 Map / 磁盘 JSON 文件 | js/ai/src/session-stores.ts:122,290 |
SessionRunner | 逐轮跑 handler、每轮结束存快照的执行器 | js/ai/src/agent.ts:279 |
runWithSession / getCurrentSession | 把 Session 绑进异步上下文,供 generate 取历史 | js/ai/src/session.ts:279,290 |
runInNewSpan | 把一段执行包进一个 OpenTelemetry span | js/core/src/tracing/instrumentation.ts:81 |
ReflectionServer | 开发期 HTTP 服务,暴露 /api/* 给 CLI/Dev UI | js/core/src/reflection.ts:73 |
RuntimeManager(工具侧) | 发现运行时、调 /api/*、拉 trace | genkit-tools/common/src/manager/manager.ts |
主线走一遍(高层):
AgentChat.send('...')发起一轮 →SessionRunner.run()把输入消息 append 进Session。- Runner 用
runWithSession把这个 Session 绑进异步上下文,再调你的 handler(内部一般是generate)。 generate通过getCurrentSession()拿到历史消息,拼进请求发给模型。- 这一整套调用里,每个 Action 都被
runInNewSpan包住,自动生成 span,层层嵌套成一棵 trace。 - 一轮结束,Runner 把 Session 的当前状态拍成快照写进 SessionStore(
maybeSnapshot)。 - 与此同时,ReflectionServer 把 span 导出到 telemetry-server;Dev UI 通过
/api/traces拉回来渲染。
3. 核心原理(逐个机制,由浅入深)
3.1 Session:一次对话的"工作内存"
-
它要解决的小问题:
generate是无状态的,总得有一个东西把这一轮产生的消息、 以及业务自定义的状态(比如"购物车里有啥")攒在一起,并且别让 handler 反手改坏了调用者的对象。 -
思路/直觉: Session 就是一个状态容器 + 版本号。它对外只给深拷贝(读), 改动走明确的方法(
addMessages/updateCustom/addArtifacts),每改一次version++。 版本号是后面"要不要存快照"的判据。 -
原理演示:
// 示意,非源码:Session 的心智模型class Session {private state; // { sessionId, messages, custom, artifacts }private version = 0;getMessages() { return structuredClone(this.state.messages); } // 只给拷贝addMessages(m) { this.state.messages.push(...m); this.version++; }updateCustom(fn) { this.state.custom = fn(this.state.custom); this.version++; }} -
真实实现:
Session类在js/ai/src/session.ts:152。注意构造时的防御——structuredClone(initialState)(session.ts:164),注释点破为什么:"Clone so we never alias (or mutate) the caller's object"。读取方法getState/getMessages一律structuredClone(session.ts:173,183),写入方法addMessages(:190)、updateCustom(:213)、addArtifacts(:230)都version++。 -
关键细节/坑:
- artifacts 按
name去重:addArtifacts(session.ts:230-258)对同名 artifact 做替换而非追加,并分别 emitartifactAdded/artifactUpdated事件——这让流式 UI 能区分"新产物"和"产物被覆盖"。 - 状态的 schema 定义在别处:
SessionState(js/ai/src/agent-types.ts:151)—— 只有sessionId? / messages? / custom? / artifacts?四个字段,custom是泛型S, 调用者可自带 Zod schema 做类型约束。
- artifacts 按
3.2 runWithSession / getCurrentSession:让 generate "隐式"拿到历史
-
它要解决的小问题: handler 里写的是
ai.generate({ prompt }),并没把 Session 当参数传进去。 那generate怎么知道要把历史消息拼上? -
思路/直觉: 用 Node 的 AsyncLocalStorage(异步上下文,一种"隐形的线程局部变量")。 Runner 在调 handler 前,把 Session 塞进一个键为
'ai.session'的异步上下文;handler 内部 任意深处调getCurrentSession()都能取回来——不用一路手动透传。 -
图示(上下文如何传递):
runWithSession(session, () => { ┌─ 异步上下文: 'ai.session' = session ─┐yourHandler(...) │ │└─ ai.generate(...) │ getCurrentSession() ──▶ 拿到 session│└─ prompt 渲染 │ → 把 session.messages 拼进请求 │}) └─────────────────────────────────────┘ -
真实实现:
runWithSession(js/ai/src/session.ts:279)一行:getAsyncContext().run('ai.session', session, fn)。getCurrentSession(js/ai/src/session.ts:290):getAsyncContext().getStore('ai.session')。Session.run(session.ts:263)是同一件事的实例方法版。- 谁在读它?比如顶层门面
ai.currentSession()(js/ai/src/genkit-ai.ts:382), 不在会话里就抛FAILED_PRECONDITION;prompt 渲染也会getCurrentSession(js/ai/src/prompt.ts:280)以注入历史。
-
关键细节: 这套异步上下文机制正是 01 章 Action 上下文的同款 底座(
js/core/src/async-context.ts),会话状态和 span 上下文都靠它跨await传递。