Stage 8 · 会话管理 + 持久化
核心目标:把 Context 升级成"会话"——有 ID、有 metadata、能列表、能按 ID 加载、能跨设备同步。
摘要:本文讲解如何将 Context 升级为带 ID、元数据、可持久化的会话。通过 SessionManager 接口与 JSON 文件版实现,支持会话的增删改查、列表过滤、全局单例及自动标题生成;并介绍 Image 多模态消息、会话与工具调用结合,最后给出自检清单与常见踩坑。
8.1 为什么需要 SessionManager
我们 Stage 6 学的 chat() 只能维护单个 Context。真实业务要的是:
- 一个用户能开 N 个会话,怎么管理?
- 会话要存数据库(不是每次 JSON.stringify 文件)
- 要按用户/时间/标签查询
- 要支持删除/重命名/收藏
- metadata 里要存什么、怎么查询
8.2 Session 类型
新建 src/chat/session.ts:
import { type Context } from "@earendil-works/pi-ai";
/**
* 会话元数据(不会发给模型,只存业务信息)
*/
export interface SessionMetadata {
userId?: string;
tags?: string[];
pinned?: boolean;
archived?: boolean;
model?: string; // 上次使用的模型(用于恢复)
[key: string]: unknown; // 允许自定义扩展字段
}
/**
* 一个完整会话
*/
export interface Session {
id: string;
title: string;
context: Context;
metadata: SessionMetadata;
createdAt: number;
updatedAt: number;
}
8.3 SessionManager 接口设计
新建 src/chat/sessions/manager.ts:
import { type Session, type SessionMetadata } from "../session.ts";
import { type ChatOptions, type ChatOnceResult } from "../chat.ts";
export interface SessionFilter {
userId?: string;
tag?: string;
pinned?: boolean;
archived?: boolean;
limit?: number;
offset?: number;
orderBy?: "updatedAt" | "createdAt";
order?: "asc" | "desc";
}
export interface SessionManager {
// CRUD
create(metadata?: Partial<SessionMetadata>): Promise<Session>;
get(id: string): Promise<Session | undefined>;
update(id: string, patch: Partial<Session>): Promise<Session>;
delete(id: string): Promise<void>;
// 列表 / 查询
list(filter?: SessionFilter): Promise<Session[]>;
count(filter?: SessionFilter): Promise<number>;
// 业务层调用入口
chat(sessionId: string, options: Omit<ChatOptions, "context">): Promise<ChatOnceResult>;
}
8.4 JSON 文件版实现
新建 src/chat/sessions/fileManager.ts:
import { mkdir, readdir, readFile, writeFile, unlink, stat } from "node:fs/promises";
import { join } from "node:path";
import { uuidv7 } from "@earendil-works/pi-ai"; // pi-ai 自带的 UUID v7
import { type Session, type SessionMetadata } from "../session.ts";
import { type SessionManager, type SessionFilter } from "./manager.ts";
import { chatWithTools } from "../toolOnce.ts";
import { type ChatOptions, type ChatOnceResult } from "../chat.ts";
export class FileSessionManager implements SessionManager {
constructor(private baseDir: string) {}
// ===== CRUD =====
async create(metadata: Partial<SessionMetadata> = {}): Promise<Session> {
await this.ensureDir();
const now = Date.now();
const session: Session = {
id: uuidv7(),
title: "新会话",
context: { messages: [], tools: [] },
metadata: { pinned: false, archived: false, ...metadata },
createdAt: now,
updatedAt: now,
};
await this.writeSession(session);
return session;
}
async get(id: string): Promise<Session | undefined> {
try {
const json = await readFile(this.filePath(id), "utf-8");
return JSON.parse(json);
} catch (err: any) {
if (err.code === "ENOENT") return undefined;
throw err;
}
}
async update(id: string, patch: Partial<Session>): Promise<Session> {
const session = await this.get(id);
if (!session) throw new Error(`Session ${id} not found`);
const updated: Session = {
...session,
...patch,
id: session.id, // 不允许改 id
createdAt: session.createdAt, // 不允许改 createdAt
updatedAt: Date.now(),
};
await this.writeSession(updated);
return updated;
}
async delete(id: string): Promise<void> {
try {
await unlink(this.filePath(id));
} catch (err: any) {
if (err.code !== "ENOENT") throw err;
}
}
// ===== 列表 =====
async list(filter: SessionFilter = {}): Promise<Session[]> {
await this.ensureDir();
const files = await readdir(this.baseDir);
const sessions: Session[] = [];
for (const file of files) {
if (!file.endsWith(".json")) continue;
try {
const json = await readFile(join(this.baseDir, file), "utf-8");
sessions.push(JSON.parse(json));
} catch {
// skip malformed files
}
}
// 过滤
let filtered = sessions.filter((s) => {
if (filter.userId && s.metadata.userId !== filter.userId) return false;
if (filter.tag && !s.metadata.tags?.includes(filter.tag)) return false;
if (filter.pinned !== undefined && s.metadata.pinned !== filter.pinned) return false;
if (filter.archived !== undefined && s.metadata.archived !== filter.archived) return false;
return true;
});
// 排序
const orderBy = filter.orderBy ?? "updatedAt";
const order = filter.order ?? "desc";
filtered.sort((a, b) => {
const diff = a[orderBy] - b[orderBy];
return order === "asc" ? diff : -diff;
});
// 分页
if (filter.offset) filtered = filtered.slice(filter.offset);
if (filter.limit) filtered = filtered.slice(0, filter.limit);
return filtered;
}
async count(filter: SessionFilter = {}): Promise<number> {
return (await this.list({ ...filter, limit: undefined })).length;
}
// ===== 业务调用 =====
async chat(sessionId: string, options: Omit<ChatOptions, "context">): Promise<ChatOnceResult> {
const session = await this.get(sessionId);
if (!session) throw new Error(`Session ${sessionId} not found`);
// 1. chat(用 chatWithTools: 自动执行工具循环,直到 final answer)
const result = await chatWithTools({
...options,
context: session.context,
} as Omit<ChatOptions, "tools" | "toolResult">);
// 2. 自动更新标题(第一条 user 消息)
if (session.title === "新会话" && session.context.messages.length > 0) {
const firstUser = session.context.messages.find((m) => m.role === "user");
if (firstUser && typeof firstUser.content === "string") {
session.title = firstUser.content.slice(0, 20);
}
}
// 3. save
await this.update(sessionId, session);
return result;
}
// ===== 内部 =====
private filePath(id: string): string {
return join(this.baseDir, `${id}.json`);
}
private async ensureDir(): Promise<void> {
try {
await stat(this.baseDir);
} catch {
await mkdir(this.baseDir, { recursive: true });
}
}
private async writeSession(session: Session): Promise<void> {
await writeFile(
this.filePath(session.id),
JSON.stringify(session, null, 2),
"utf-8",
);
}
}
8.5 全局单例
新建 src/chat/sessions/index.ts:
import { FileSessionManager } from "./fileManager.ts";
// 默认数据目录(项目根/sessions)
export const manager = new FileSessionManager("./sessions");
export type { Session, SessionMetadata } from "../session.ts";
export type { SessionManager, SessionFilter } from "./manager.ts";
export { FileSessionManager } from "./fileManager.ts";
业务代码用:
import { manager } from "./chat/sessions/index.ts";
// 创建会话
const session = await manager.create({ userId: "user_001" });
// 调用
const result = await manager.chat(session.id, {
userMessage: "你好",
providerId: "deepseek",
modelId: "deepseek-v4-flash",
onText: (d) => process.stdout.write(d),
});
// 列出会话
const list = await manager.list({ userId: "user_001" });
8.6 自动标题进阶:调模型生成
简单的"取首条 user 消息前 20 字"够用,但更好的方案是让模型生成:
新建 src/chat/autoTitle.ts:
import { chatOnce } from "./chatOnce.ts";
export async function smartTitle(context: any): Promise<string> {
const firstUser = context.messages.find((m: any) => m.role === "user");
if (!firstUser) return "新会话";
const userMsg = typeof firstUser.content === "string"
? firstUser.content
: "[多模态消息]";
try {
const result = await chatOnce({
systemPrompt: "为对话生成一个 5-10 字的中文标题。只输出标题,不要标点符号。",
userMessage: userMsg,
providerId: "deepseek",
modelId: "deepseek-v4-flash",
stream: false,
});
return result.text.trim().slice(0, 20);
} catch {
return userMsg.slice(0, 20);
}
}
第一步,在 FileSessionManager 文件顶部加导入:
import { smartTitle } from "../autoTitle.ts";
第二步,找到 chat() 里那段:
// 2. 自动更新标题(第一条 user 消息)
if (session.title === "新会话" && session.context.messages.length > 0) {
const firstUser = session.context.messages.find((m) => m.role === "user");
if (firstUser && typeof firstUser.content === "string") {
session.title = firstUser.content.slice(0, 20);
}
}
替换成:
// 2. 自动生成标题(调模型)
if (session.title === "新会话" && session.context.messages.length > 0) {
session.title = await smartTitle(session.context);
}
注意: smartTitle 是异步函数,会阻塞 save 几十毫秒。生产环境可以考虑先粗暴的用一个临时标题先回复,让后台去异步调模型生标题。就作为一个衍生的进阶练习吧。
8.7 Image 多模态
UserMessage.content 可以是 (TextContent | ImageContent)[],支持图文混合(ContentBlock 是 Stage 3 里你自己 type alias 的本地别名,pi-ai 不导出):
import { readFileSync } from "node:fs";
const imageBase64 = readFileSync("./cat.jpg").toString("base64");
context.messages.push({
role: "user",
content: [
{ type: "text", text: "这是什么?" },
{
type: "image",
data: imageBase64,
mimeType: "image/jpeg",
},
],
timestamp: Date.now(),
});
怎么查看你的模型是否支持 vision?
Model 对象上有一个 input 字段:("text" | "image")[]。
还记得4.12 附加的那个demo list-models吗?把里面改一下:
import('@earendil-works/pi-ai/providers/all').then(({ builtinModels }) => {
const models = builtinModels();
const all = models.getModels('qwen-token-plan-cn');
console.log('Qwen Token Plan CN 模型列表(共 ' + all.length + ' 个):');
for (const m of all) {
const vision = m.input.includes('image') ? '✅' : '❌'; // 是否支持图片视觉识别
console.log(` ${m.id.padEnd(30)} — ${m.name.padEnd(20)} [reasoning:${m.reasoning}] [ctx:${m.contextWindow}] [vision:${vision}]`);
}
});
输出示例(pi-ai 0.85.1, 2026-09):
Qwen Token Plan CN 模型列表(共 18 个):
MiniMax-M2.5 — MiniMax-M2.5 [reasoning:true] [ctx:196608] [vision:❌]
deepseek-v3.2 — DeepSeek V3.2 [reasoning:true] [ctx:131072] [vision:❌]
deepseek-v4-flash — DeepSeek V4 Flash [reasoning:true] [ctx:1000000] [vision:❌]
deepseek-v4-flash-0731 — DeepSeek V4 Flash 0731 [reasoning:true] [ctx:1000000] [vision:❌]
deepseek-v4-pro — DeepSeek V4 Pro [reasoning:true] [ctx:1000000] [vision:❌]
deepseek-v4-pro-0813 — DeepSeek V4 Pro 0813 [reasoning:true] [ctx:1000000] [vision:❌]
glm-5 — GLM-5 [reasoning:true] [ctx:202752] [vision:❌]
glm-5.1 — GLM-5.1 [reasoning:true] [ctx:202752] [vision:❌]
glm-5.2 — GLM-5.2 [reasoning:true] [ctx:1000000] [vision:❌]
kimi-k2.5 — Kimi K2.5 [reasoning:true] [ctx:262144] [vision:✅]
kimi-k2.6 — Kimi K2.6 [reasoning:true] [ctx:262144] [vision:✅]
kimi-k2.7-code — Kimi K2.7 Code [reasoning:true] [ctx:262144] [vision:✅]
qwen3.6-flash — Qwen3.6 Flash [reasoning:true] [ctx:1000000] [vision:✅]
qwen3.6-plus — Qwen3.6 Plus [reasoning:true] [ctx:1000000] [vision:✅]
qwen3.7-max — Qwen3.7 Max [reasoning:true] [ctx:1000000] [vision:❌]
qwen3.7-plus — Qwen3.7 Plus [reasoning:true] [ctx:1000000] [vision:✅]
qwen3.8-flash — Qwen3.8 Flash [reasoning:true] [ctx:1000000] [vision:✅]
qwen3.8-max — Qwen3.8 Max [reasoning:true] [ctx:1000000] [vision:✅]
你用的 provider 里,如果所有模型都是 vision:❌,图片发过去会报错。换支持 vision 的模型(比如小米 MiMo)。
注意:
- 只有支持 vision 的模型才能接收(看
model.input.includes('image')) - 序列化时 base64 占空间大,生产建议存到对象存储,context 里只存 URL
8.8 实战:src/chat/08-demo-session.ts
import { manager } from "./sessions/index.ts";
async function main() {
// 1. 创建会话
console.log("=== 创建会话 ===");
const session = await manager.create({
userId: "demo_user",
tags: ["tech-support"],
});
console.log(`ID: ${session.id}`);
console.log(`Title: ${session.title}`);
// 2. 多轮对话
console.log("\n=== 第 1 轮 ===");
await manager.chat(session.id, {
userMessage: "你好,我是新用户",
providerId: "deepseek",
modelId: "deepseek-v4-flash",
onText: (d) => process.stdout.write(d),
});
console.log("\n\n=== 第 2 轮 ===");
await manager.chat(session.id, {
userMessage: "帮我写一首关于春天的短诗",
providerId: "deepseek",
modelId: "deepseek-v4-flash",
onText: (d) => process.stdout.write(d),
});
// 3. 重新加载会话
console.log("\n\n=== 重新加载 ===");
const loaded = await manager.get(session.id);
console.log(`Title: ${loaded?.title}`);
console.log(`Messages: ${loaded?.context.messages.length}`);
console.log(`UpdatedAt: ${new Date(loaded!.updatedAt).toLocaleString()}`);
// 4. 列出会话
console.log("\n=== 列出用户所有会话 ===");
const list = await manager.list({ userId: "demo_user" });
for (const s of list) {
console.log(` [${s.id.slice(0, 8)}] ${s.title} (${s.context.messages.length} 条消息)`);
}
// 5. 删除会话
console.log("\n=== 删除会话 ===");
await manager.delete(session.id);
const after = await manager.get(session.id);
console.log(`删除后查找: ${after === undefined ? "✓ 已删除" : "✗ 还在"}`);
}
main().catch(console.error);
8.9 会话 + 工具调用结合
Stage 8 讲了会话持久化,Stage 7 讲了工具调用。真实业务两者要结合:
- 用户开一个会话
- 会话里多轮对话,某些轮触发工具调用
- 工具结果和模型回复都存进 context
- 关闭再打开,对话历史完整保留
新建 src/chat/08-demo-session-tools.ts:
import "./tools/index.ts"; // 触发工具注册(跟 Stage 7 一样)
import { manager } from "./sessions/index.ts";
import { registeredToolsAsArray } from "./tools/index.ts";
async function main() {
// 1. 创建会话
const session = await manager.create({ userId: "demo_user" });
console.log(`创建会话: ${session.id}\n`);
// 2. 多轮对话(带工具)
const tools = registeredToolsAsArray();
console.log("=== 第 1 轮:问天气(会触发 get_weather) ===");
await manager.chat(session.id, {
userMessage: "北京今天天气怎么样?",
providerId: "deepseek",
modelId: "deepseek-v4-flash",
tools, // ★ 传入工具列表
onText: (d) => process.stdout.write(d),
});
process.stdout.write("\n");
console.log("\n=== 第 2 轮:问时间(会触发 get_current_time) ===");
await manager.chat(session.id, {
userMessage: "现在几点了?",
providerId: "deepseek",
modelId: "deepseek-v4-flash",
tools,
onText: (d) => process.stdout.write(d),
});
process.stdout.write("\n");
// 3. 重新加载会话(验证工具调用历史也存下来了)
console.log("\n=== 重新加载会话 ===");
const loaded = await manager.get(session.id);
console.log(`Title: ${loaded?.title}`);
console.log(`Messages: ${loaded?.context.messages.length}`);
// 4. 用同一个会话继续聊(工具结果还在上下文里)
console.log("\n=== 第 3 轮:用上下文追问 ===");
await manager.chat(session.id, {
userMessage: "刚才查的天气怎么样?总结一下",
providerId: "deepseek",
modelId: "deepseek-v4-flash",
tools,
onText: (d) => process.stdout.write(d),
});
process.stdout.write("\n");
}
main().catch(console.error);
预期输出:
创建会话: 01993...
=== 第 1 轮:问天气(会触发 get_weather) ===
北京今天天气晴朗,气温 25°C,体感舒适。
=== 第 2 轮:问时间(会触发 get_current_time) ===
现在是北京时间 2026-09-12 16:23。
=== 重新加载会话 ===
Title: 北京今天天气怎么样?
Messages: 6 // user + assistant(toolCall) + toolResult + assistant(text) × 2轮
=== 第 3 轮:用上下文追问 ===
刚才查到北京天气晴朗,25°C,体感舒适,很适合外出。
关键点:
manager.chat(sessionId, { ..., tools })—— 传 tools 就能调工具,会话自动把工具结果存进 context- 重新加载后,
context.messages里有完整的工具调用历史(toolCall + toolResult) - 第 3 轮追问"刚才查的天气" —— 模型能从上下文里看到之前的工具结果,回答有连续性
- 关闭进程再启动,会话数据还在(文件版持久化)
8.10 Stage 8 自检清单
-
src/chat/session.ts类型定义 -
src/chat/sessions/manager.ts接口定义 -
src/chat/sessions/fileManager.ts实现 -
src/chat/sessions/index.ts全局单例 -
src/chat/autoTitle.ts智能标题 -
src/chat/08-demo-session.ts跑通 - 创建会话 → 多轮 → 重新加载 → 列出 → 删除全流程
-
src/chat/08-demo-session-tools.ts跑通 - 创建会话 → 多轮工具调用 → 重新加载 → 列出 → 总结
- 自动标题生成(首条 user 消息前 20 字)
- 验证:关闭进程再启动,会话数据还在
- 进阶:用
smartTitle替换简单标题 - 进阶:加 image 消息测试 vision 模型(如果支持)
8.11 常见踩坑
| 现象 | 真原因 | 修法 |
|---|---|---|
sessions/ 目录不存在 | 第一次写之前没建 | ensureDir() 处理 |
| 文件锁冲突 | JSON 文件版并发写会出问题 | 加 lock 或换 SQLite、mongoDB、mysql等数据库方案 |
uuidv7 没导出 | pi-ai 没在入口 re-export | import { uuidv7 } from "@earendil-works/pi-ai" |
| 重新加载后丢失 metadata | JSON 解析默认行为问题 | 用 JSON.parse(json) as Session |
| Image base64 太大 | 不压缩 | 用 sharp 库压缩到 1024px 以下 |
| Context 太大 | messages 累积没清理 | 加 token 计数 + 自动压缩(Stage 9) |
&spm=1001.2101.3001.5002&articleId=165121983&d=1&t=3&u=0abd30dcb54f4b648d90ff905eab5205)
222

被折叠的 条评论
为什么被折叠?



