8. 会话管理 + 持久化 — 基于@earendil-works/pi-ai,学习搭建多模型业务层框架(国内模型)

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-exportimport { uuidv7 } from "@earendil-works/pi-ai"
重新加载后丢失 metadataJSON 解析默认行为问题JSON.parse(json) as Session
Image base64 太大不压缩用 sharp 库压缩到 1024px 以下
Context 太大messages 累积没清理加 token 计数 + 自动压缩(Stage 9)
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值