第 5 章 · 会话与上下文管理
本章目标:理解 Flue 的会话模型与 ID 寻址机制,掌握跨对话上下文保持的原理,学会设计合理的会话生命周期。
5.1 会话是 Agent 的"记忆单元"
没有会话的 LLM 调用是失忆的:每条消息都是全新开始。Flue 中每个 agent 实例都绑定一个会话(session),会话按 ID 持久化:
text
Agent 函数(定义) 会话实例(运行时状态)
┌─────────────────┐ ┌──────────────────────┐
│ export function │ │ id: "ticket-1024" │
│ Triage() {...} │ ────▶ │ messages: [...历史] │
│ │ 按 ID │ state: 持久标志位 │
└─────────────────┘ 实例化 └──────────────────────┘- Agent 定义是无状态的函数模板,全局一份;
- 会话实例是有状态的运行时对象,每个 ID 一份;
- 同一个 agent 可以同时服务成千上万个会话而互不干扰。
5.2 用 ID 建立多轮对话
ID 的语义完全由你的业务决定——用户 ID、工单号、GitHub issue 编号、随机字符串皆可:
bash
# CLI 方式:--id 创建/续接一个具名会话
npx flue run src/agents/assistant.ts --id crab-owner-42 --message \
"我想养一只宠物蟹,帮我想个名字"
npx flue run src/agents/assistant.ts --id crab-owner-42 --message \
"就选你说的第一个名字,给我三个它的昵称"
# 第二条消息能接住第一条的话题——因为同 ID 共享消息历史typescript
// HTTP 方式:POST /:id 中的 :id 就是会话标识
// src/app.ts 挂载路由后:
//
// curl -X POST http://localhost:5173/agents/support/user-8817 \
// -H 'content-type: application/json' \
// -d '{"text": "我的订单还没到"}'
//
// user-8817 这个用户的后续消息都会落在同一个会话里ID 设计建议
把 ID 当作业务主键用:客服场景用 user-{uid}, 运维场景用 {repo}-{issue}。这样天然实现"一人一会话、一事一会话",且重启后凭 ID 即可恢复。
5.3 上下文的组成与自动压缩
发给模型的完整上下文由三部分组成,其中会话历史会随对话增长:
typescript
'use agent';
import { useModel } from '@flue/runtime';
export function LongTaskAgent() {
useModel('anthropic/claude-sonnet-4-6', {
// 长任务必配:接近上限时 Flue 自动压缩历史
compaction: {
reserveTokens: 30000, // 提前触发:已用 token 超过 上限-30000 时压缩
keepRecentTokens: 16000, // 最近 16k token 的消息保持原文
model: 'anthropic/claude-haiku-4-5', // 摘要工作交给便宜模型
},
});
return `
执行一次完整的代码库安全审计:
逐目录扫描、记录发现、汇总报告。
任务很长,请边做边在回复中维护"当前进度"小结。`;
}压缩机制的工作方式:旧历史折叠成摘要 + 最近消息保留原文,会话不中断地继续。这让单个会话可以跑得比任何模型的上下文窗口都长——这正是第 1 章说的 Durability 特性的一部分。
5.4 跨轮持久化的自定义状态
消息历史之外,agent 还有自己的持久状态槽位——usePersistentState:
typescript
'use agent';
import { useModel, usePersistentState, useTool } from '@flue/runtime';
import { defineTool } from '@flue/runtime';
import * as v from 'valibot';
// 一个让模型主动登记结论的工具,写入持久状态
const recordFinding = defineTool({
name: 'record_finding',
description: '把一条审计发现记入会话级清单。',
input: v.object({ severity: v.string(), detail: v.string() }),
async run() { return { output: '已记录' }; }, // 简化示例
});
export function Auditor() {
useModel('anthropic/claude-sonnet-4-6');
useTool(recordFinding);
// findings 在整个会话生命周期内持久保存
const [findings] = usePersistentState<string[]>('findings', []);
return `
你是安全审计员。
${findings.length > 0
? `已有 ${findings.length} 条记录,继续扫描未覆盖的目录。`
: '开始第一轮全库扫描。'}
每完成一项检查就用 record_finding 登记。`;
}注意与第 3 章"重渲染"的结合:findings.length 变了,下一轮指令就跟着变——这就是"指令反映当前状态"的实际用法。
5.5 会话生命周期设计
| 场景 | 推荐 ID 策略 | 生命周期 |
|---|---|---|
| 个人助手 | user-{uid} | 长期,跟随用户 |
| 工单处理 | ticket-{no} | 工单关闭即归档 |
| CI 任务 | run-{pipeline_id} | 单次流水线 |
| 定时巡检 | patrol-{date} | 按天滚动 |
两条实践建议:
- 宁可会话粒度细:一个大杂烩会话会让旧上下文稀释新任务的注意力;无关任务开新会话;
- 依赖压缩而非手动清理:与其自己截断历史,不如配置好 compaction 让框架处理。
typescript
// 会话 ID 的类型化封装建议——把 ID 语义写进类型里
// 避免各处手拼字符串导致格式漂移
type SessionId<T extends string> = `${T}-${string}`;
const userSession = (uid: string) => `user-${uid}` as const; // user-8817
const ticketSession = (no: number) => `ticket-${no}` as const; // ticket-1024
const runSession = (pid: string) => `run-${pid}` as const; // run-ci-7788
// 统一出口:所有业务模块都从这里拿会话 ID
export const sessions = { userSession, ticketSession, runSession };本章小结
- Agent 定义无状态,会话实例有状态,靠 ID 关联;
- ID 语义业务自定义,CLI 用
--id、HTTP 用POST /:id; - 长会话靠 compaction 自动压缩:摘要旧历史 + 保留近期原文;
usePersistentState提供消息之外的业务状态持久化;- 会话粒度宜细不宜粗,压缩交给框架。
🧪 随堂测验
点击你认为正确的选项。答错时会展示正确答案与原因解析。
1. Flue 中会话实例和 Agent 函数的关系是?
2. HTTP 方式向指定会话发消息的路由是?
3. compaction 触发时会发生什么?
4. 关于会话 ID 的设计,下列做法最合理的是?
🛠️ 动手实践
- 为同一个 agent 开两个不同 ID 的会话,验证它们的消息互不可见。
- 给长任务 agent 配置 compaction 并故意跑超长任务,观察摘要替换原文的行为。
- 用
usePersistentState实现"已询问过的问题列表",让 agent 避免重复提问。
会话有了记忆。但光会说话不够——下一章让 Agent动手调用你的代码。