Qwen Code Session Recap 设计解析:AI 编程助手的"离开后回来"会话摘要机制
【免费下载链接】qwen-codeAn open-source AI coding agent that lives in your terminal.项目地址: https://gitcode.com/GitHub_Trending/qw/qwen-code
Session Recap(会话回顾)是 Qwen Code 中一项"最佳努力"(best-effort)的辅助能力:当用户离开终端一段时间后回来,或在任何时刻通过/recap命令主动请求时,CLI 会基于当前会话历史生成一句 1–2 句话的"上次进行到哪了"摘要(高层面任务 → 下一步动作),并以视觉上区别于真实助手回复的样式插入会话流。本文以 docs/design/session-recap/session-recap-design.md 设计文档为骨架,结合 packages/core/src/services/sessionRecap.ts、useAwaySummary.ts、recapCommand.ts 等源码实现,完整讲解该功能的三种触发路径、提示词与结构化输出设计、历史过滤策略、并发边界、配置项与可观测性,并给出可直接落地的配置与调用方法。
读完本文,你将掌握:如何开启并调优自动 recap、何时使用/recap手动触发、远程客户端如何通过 Daemon HTTP 接口获取会话 recap,以及这套机制在"绝不打断主流程、绝不向用户抛错"约束下是如何在源码层面被保证的。
设计动机与核心原则
用户几天后通过/resume恢复一个旧会话时,仅靠重新加载历史消息并不能解决一个真实的 UX 痛点:用户需要翻过多页历史才能想起来"我当时在做什么、接下来该做什么"。Session Recap 的目标是在用户回到会话时主动呈现一句简短的摘要,其设计遵循三条原则:
- 内容结构固定:高层任务(正在做什么)→ 下一步(接下来做什么);
- 视觉可区分:摘要必须与真实助手回复在视觉上明显不同,避免被误认为新的模型输出;
- 最佳努力:任何失败都必须静默处理,绝不能破坏主流程。
从实现上看,这一"绝不打扰用户"的承诺贯穿始终:核心服务函数在try/catch中兜底返回null(见 sessionRecap.ts),UI 层则保证失败时不渲染任何内容。
三种触发路径与统一入口
Session Recap 有三条触发路径,全部汇入同一个底层函数generateSessionRecap()(位于 packages/core/src/services/sessionRecap.ts),从而保证行为完全一致:
| 触发方式 | 条件 | 实现 |
|---|---|---|
| 手动命令 | 用户运行/recap | recapCommand.ts 调用同一底层服务 |
| 自动触发 | 终端失焦(DECSET 1004 focus 协议)≥ 5 分钟 + 焦点回归 + 流状态为Idle | useAwaySummary.ts:5 分钟失焦计时器 +useFocus事件监听 |
| Daemon HTTP | 远程客户端调用POST /session/:id/recap | server.ts路由 →bridge.generateSessionRecap(ext-method 往返)→ acpAgent.ts 调用generateSessionRecap(session.getConfig(), signal) |
自动触发受general.showSessionRecap设置约束(默认关闭——显式选择加入,避免在用户账单上静默增加环境 LLM 调用);而手动命令与 Daemon HTTP 路由忽略该设置,因为调用者是显式发起请求。
Daemon 访问路径
Daemon 路由采用非严格门控(non-strict-gated),与/session/:id/prompt的姿态一致——recap 消耗 token 但不改变任何状态。能力标签session_recap会在/capabilities.features上通告该路由(见 capabilities.ts)。SDK 侧提供了两个辅助方法:
DaemonClient.recapSession(sessionId, opts):见 DaemonClient.ts,直接POST /session/:id/recap;DaemonSessionClient.recap(opts):见 DaemonSessionClient.ts。
完整的线上协议与错误封装见 docs/developers/qwen-serve-protocol.md 中POST /session/:id/recap一节。
需要特别注意的是 SDK 文档中明确记录的契约细节(DaemonClient.ts):
- 非严格变更门控:姿势与
/session/:id/prompt相同(花费 token 但不改动状态); - 绕过默认 30s 超时:
recapSession直接调用_fetch,不套用每调用fetchTimeoutMs包装器,因为底层 side-query 在慢模型下可能超过默认 30 秒; - 向前兼容:旧版本 daemon(不支持 recap)会返回 404——调用前应先预检
caps.features.session_recap; recap可能为null:历史过短或模型瞬时失败时,返回 200 但recap: null(见 types.ts 中DaemonSessionRecapResult的契约)。
v1 的取消语义
v1 中不提供取消(cancellation absent):路由不监听 HTTP 客户端断开,没有把AbortSignal穿入bridge.generateSessionRecap,ACP 子进程处理器向核心助手传入的是一个永不被中止的new AbortController().signal(见 acpAgent.ts),目前尚无跨进程中止管线。唯一的"天花板"是 bridge 侧 60 秒的SESSION_RECAP_TIMEOUT_MS兜底超时,以及针对 ACP 通道死亡的传输关闭竞态。仅单独接线 HTTP 侧的 AbortController 只是表面功夫——子进程侧的 LLM 调用仍会跑完,因此在缺少跨进程中止组件的情况下无法实现端到端取消。这对 v1 是可接受的:recap 是短查询(单次尝试 side-query,maxOutputTokens: 300,典型耗时约 1–5 秒)。未来可以引入基于 request-id 的取消 ext-method,在带宽成本合理时打通完整的端到端取消。
架构与文件职责
设计文档给出了完整的调用链架构图,核心流程为:
AppContainer.tsx isFocused = useFocus() isIdle = streamingState === Idle ├─→ useAwaySummary({enabled, config, isFocused, isIdle, addItem}) │ └─→ 5 min blur timer + idle/dedupe gates │ ↓ └─→ recapCommand (slash) ─→ generateSessionRecap(config, signal) ↓ packages/core/services/sessionRecap.ts ↓ GeminiClient.generateContent (fastModel + tools:[]) addItem({type: 'away_recap', text}) ─→ HistoryItemDisplay └─ AwayRecapMessage rendered inline like any other history item (※ + bold "recap: " + italic content, all dim); scrolls naturally with the conversation涉及的文件与职责对应关系如下:
| 文件 | 职责 |
|---|---|
| packages/core/src/services/sessionRecap.ts | 一次性 LLM 调用 + 历史过滤 + 标签提取 |
| packages/cli/src/ui/hooks/useAwaySummary.ts | 自动触发 React hook |
| packages/cli/src/ui/commands/recapCommand.ts | /recap手动入口 |
| packages/cli/src/ui/components/messages/StatusMessages.tsx | AwayRecapMessage渲染器(※+ 加粗recap:+ 斜体内容,全部弱化色) |
| packages/cli/src/ui/types.ts | HistoryItemAwayRecap类型 |
| packages/cli/src/ui/components/HistoryItemDisplay.tsx | 将away_recap历史项分派给渲染器 |
| packages/cli/src/config/settingsSchema.ts | general.showSessionRecap+general.sessionRecapAwayThresholdMinutes设置 |
值得注意的 UI 细节(源码注释明确说明):AwayRecapMessage复刻了 Claude Code 的 away-summary 渲染方式——固定 2 列宽的※前缀,加粗的recap:标签 + 斜体内容,全部使用次要色(dim)。它作为普通历史项渲染,随对话自然滚动,而不是固定在输入框上方(见 StatusMessages.tsx)。HistoryItemAwayRecap的注释同样强调:作为常规历史项内联渲染,与 Claude Code 的away_summary消息一致,随会话滚动、不粘顶(types.ts)。
Prompt 设计:让模型只当一个 recap 生成器
System Prompt
generationConfig.systemInstruction会替换主 Agent 的系统提示词,使模型在这一次调用中只充当 recap 生成器,而不是编码助手。
需要注意:GeminiClient.generateContent()内部会经过getCustomSystemPrompt(),将用户的记忆(QWEN.md / 托管 auto-memory)作为后缀追加。因此最终 system prompt 是recap prompt + 用户记忆——这对 recap 是有用的项目上下文,而非泄漏。
源码中RECAP_SYSTEM_PROMPT(sessionRecap.ts)与设计文档的要点一一对应:
- 40 词以内、1–2 句平实句子(无 markdown / 列表 / 标题);中文场景约按 80 字符预算;
- 第一句讲高层任务,然后给出具体的下一步动作;
- 明确禁止:罗列已完成事项、复述工具调用、状态汇报;
- 匹配对话的主导语言(英文或中文);
- 输出包裹在
<recap>...</recap>标签内,标签外不得有任何内容。
源码中的示例:
<recap>Debugging the auth retry race condition. Next: add deterministic timing to the integration test.</recap>用户侧 prompt 同样强制约束:'Generate the recap now. Wrap it in <recap>...</recap>. Nothing outside the tags.'(sessionRecap.ts)。
结构化输出与提取
模型被要求把答案包裹在<recap>...</recap>中。原因:部分模型(GLM 家族、推理模型)会在最终答案前写一段"思考"文本,直接返回原始文本会把那段推理泄漏到 UI 中。
extractRecap()提供三级回退(sessionRecap.ts):
- 双标签齐全:取
<recap>...</recap>之间的内容(首选路径,正则/<recap>([\s\S]*?)<\/recap>/i); - 仅有开标签(例如
maxOutputTokens截断了闭标签):取开标签之后的所有内容; - 完全没有标签:返回空字符串 → 服务返回
null→ UI 不渲染任何内容。
第三级是"跳过而非展示错误内容"策略——展示模型的推理前导文本比不展示 recap 更糟糕。
调用参数
| 参数 | 值 | 原因 |
|---|---|---|
model | getFastModel() ?? getModel() | recap 不需要前沿模型 |
tools | [] | 一次性查询,不涉及工具调用 |
maxOutputTokens | 300 | 为 1–2 句短句 + 标签留足余量 |
temperature | 0.3 | 基本确定性输出,保留少量自然变化 |
systemInstruction | 上述仅 recap 的提示词 | 替换主 Agent 的角色定义 |
从源码看,这些参数经由runSideQuery生效(sessionRecap.ts),其中purpose: 'session-recap'、maxAttempts: 1——注释明确说明:recap 是"best-effort cosmetic"(尽力而为的外观功能),不值得消耗默认的 7 次重试。
历史过滤:只把"对话"喂给模型
geminiClient.getChat().getHistory()返回的Content[]包含多种内容:
user/model文本消息model的functionCall部分user的functionResponse部分(可能包含完整文件内容)model的 thought 部分(part.thought/part.thoughtSignature,即模型的隐藏推理)
filterToDialog()(sessionRecap.ts)只保留有非空文本且不是 thought的user/model部分,原因有二:
- 工具调用 / 响应:单个
functionResponse可达 10K+ token,30 条这样的消息会让 recap LLM 淹没在无关细节中——既浪费 token,又会让 recap 偏向"调用了 X 工具去读 Y 文件"这类实现噪音; - thought 部分:承载模型的内部推理。纳入其中可能把隐藏的思维链当作对话,并在 recap 文本中将其表面化。
此外,该函数还调用了getStartupContextLength(history)切掉启动上下文,并对每条文本执行stripSystemReminderBlocks()剥除系统提醒块(如STARTUP_SKILL_LIST、ADDED_MCP_TOOLS、PLAN_MODE_REMINDER、IDE_CONTEXT等)。这一点有单元测试直接验证:sessionRecap.test.ts 断言最终的序列化输入中不包含任何系统提醒块内容,同时保留真实用户消息。
在丢弃空消息之后,takeRecentDialog()(sessionRecap.ts)切片到最近 30 条消息(RECENT_MESSAGE_WINDOW = 30),并且拒绝让切片起始于悬空的 model/tool 响应——即向后推进到第一条user角色消息为止,保证回合结构完整。
并发与边界情况
自动触发 hook 状态机
useAwaySummary维护三个 ref(useAwaySummary.ts):
| Ref | 含义 |
|---|---|
blurredAtRef | 失焦开始时间(在焦点回归前不清除) |
recapPendingRef | 是否有一次 LLM 调用在途 |
inFlightRef | 当前在途的AbortController |
useEffect依赖数组为[enabled, config, isFocused, isIdle, addItem, thresholdMs],各事件的处理逻辑:
| 事件 | 动作 |
|---|---|
!enabled \|\| !config | 中止在途调用 + 清空inFlightRef+ 清空blurredAtRef |
!isFocused且blurredAtRef === null | 设置blurredAtRef = Date.now() |
isFocused且blurredAtRef === null | 直接返回(无失焦周期可处理——首次渲染或短暂失焦重置之后) |
isFocused且失焦时长 < 5 分钟 | 清空blurredAtRef,等待下一个失焦周期 |
isFocused且失焦 ≥ 5 分钟且recapPendingRef | 返回(去重) |
isFocused且失焦 ≥ 5 分钟且!isIdle | 保留blurredAtRef,等待本轮结束(isIdle在依赖中,流式完成后 effect 会重新触发) |
isFocused且失焦 ≥ 5 分钟且shouldFireRecap返回 false | 清空blurredAtRef并返回——会话自上次 recap 以来没有足够的新进展(要求 ≥ 2 个用户回合,复刻 Claude Code) |
isFocused且所有条件满足 | 清空blurredAtRef、置recapPendingRef = true、创建AbortController、发起 LLM 请求 |
.then回调会重新检查isIdleRef.current:如果用户在 LLM 运行期间已经开始新一轮对话,迟到的 recap 会被丢弃,避免在回合中途插入。.finally清空recapPendingRef,并且仅当inFlightRef.current === controller时才清空inFlightRef(避免覆盖更新的 controller)。另有一个独立的useEffect在卸载时中止在途 controller。
值得展开的细节:history通过historyRef在触发时读取,而不是加入 effect 依赖——这样历史变化不会导致每次消息都重新评估(useAwaySummary.ts)。
去重门控(Dedup gates)
shouldFireRecap()(useAwaySummary.ts)实现与 Claude CodeSc1/Rc1/Ic1对齐的门控:
MIN_USER_MESSAGES_TO_FIRE = 3:至少要有 3 条用户回合总数(sentToModel !== false才算,steer 消息不计入);MIN_USER_MESSAGES_SINCE_LAST_RECAP = 2:若历史中已有 recap,则自上次 recap 之后至少需要 2 条新用户回合才能再次触发。
后者防止用户两次短暂 alt-tab 且中间没有做任何新工作时,产生背靠背的重复 recap。这两个门控都有测试覆盖(useAwaySummary.test.ts):例如 2 条真实用户消息 + 1 条 steer 消息时(总计 3 条但只有 2 条真实回合),不会触发调用;历史中已有 recap 且其后只有 1 条新用户回合时,同样不会触发。
自动 recap 的持久化
一个容易被忽略但很关键的行为:自动触发的 recap 会通过config.getChatRecordingService()?.recordSlashCommand({ phase: 'result', rawCommand: '/recap', outputHistoryItems: [...] })记录(useAwaySummary.ts),镜像手动/recap由斜杠命令处理器执行的记录方式,从而保证自动 recap 在/resume后也能存活。只记录result阶段——若记录invocation阶段,会在恢复时重放一行伪造的> /recap用户行。该行为同样有测试覆盖(useAwaySummary.test.ts)。
/recap门控
CommandContext.ui.isIdleRef暴露当前流状态(镜像已有的btwAbortControllerRef模式)。在交互模式下,recapCommand在!isIdleRef.current或pendingItem !== null时拒绝执行(recapCommand.ts),返回错误消息"另一个操作正在进行中"。仅判断pendingItem是不够的,因为正常的模型回复运行在streamingState === Responding且pendingItem === null的状态下。
其他行为细节:/recap在无配置时返回错误消息;非交互模式下,recap 结果作为messageType: 'info'的消息返回而不是插入历史项;若 recap 为null,交互模式返回信息提示"对话上下文还不足以生成 recap"(recapCommand.ts)。
配置与模型选择
用户可配置项
| 设置 | 默认值 | 说明 |
|---|---|---|
general.showSessionRecap | false | 仅影响自动触发。手动/recap忽略此设置。 |
general.sessionRecapAwayThresholdMinutes | 5 | 失焦多少分钟后,在焦点回归时触发自动 recap。与 Claude Code 默认值一致。 |
fastModel | 未设置 | 推荐设置(如qwen3-coder-flash),以获得又快又便宜的 recap。 |
这些设置在 settingsSchema.ts 中注册为showInDialog: true的常规项(requiresRestart: false,可在设置对话框直接修改)。sessionRecapAwayThresholdMinutes还声明了minimum: 1,而useAwaySummary侧对非正值会回退到 5 分钟默认值(DEFAULT_AWAY_THRESHOLD_MINUTES = 5,见 useAwaySummary.ts)。
showSessionRecap默认关闭的理由在源码注释中写得很清楚(settingsSchema.ts):环境性的后台 LLM 调用不应在用户不知情的情况下被默默开启,尤其当fastModel未设置时,调用会落在主编码模型上。手动/recap不受此限制。
模型回退
config.getFastModel() ?? config.getModel():
- 用户设置了
fastModel且对当前认证类型有效 → 使用fastModel; - 否则 → 回退到主会话模型(可用,只是更贵更慢)。
可观测性
createDebugLogger('SESSION_RECAP')输出调试日志(sessionRecap.ts),包括:
- recap 路径捕获的异常(
debugLogger.warn,如"Recap generation failed: ..."); - 各类跳过原因的
debugLogger.debug信息:无 LLM 客户端、历史过短(少于 2 条消息)、过滤后无对话消息、模型返回空文本、标签提取失败、被信号中止等。
所有失败对用户完全透明——recap 是辅助功能,绝不向 UI 抛错。开发者可在调试日志文件中按[SESSION_RECAP]标签 grep:日志默认写入~/.qwen/debug/<sessionId>.txt(latest.txt符号链接指向当前会话);通过QWEN_DEBUG_LOG_FILE=0禁用。
明确排除的范围(Out of Scope)
| 项 | 原因 |
|---|---|
/recap的进度 UI(spinner / pendingItem) | 3–5 秒等待可以接受;增加复杂度不值得。 |
| 自动化测试 | 服务较小(约 150 行),先手动端到端验证;单元测试可单独 PR 落地(注:仓库中已存在 sessionRecap.test.ts 与 useAwaySummary.test.ts,覆盖系统提醒剥离与去重门控等关键路径)。 |
| 本地化提示词 | system prompt 是给模型的;英文是最可靠的基底。输出语言由模型根据对话自行选择。 |
QWEN_CODE_ENABLE_AWAY_SUMMARY环境变量 | Claude Code 用它来在遥测关闭时保持功能开启;Qwen Code 当前的遥测模型不需要这个开关。 |
/resume完成后的自动 recap | 是自然的后续演进,但需要在useResumeCommand中寻找挂载点;超出本 PR 范围。 |
实战速查
在交互终端中:
# 手动生成一次 recap(不受 showSessionRecap 限制) /recap # 开启自动 recap(写入 settings.json 或通过设置对话框) # general.showSessionRecap: true # general.sessionRecapAwayThresholdMinutes: 5 # 可调,如 10通过 Daemon HTTP / TypeScript SDK:
import { DaemonClient } from 'qwen-code-sdk'; const client = new DaemonClient({ baseUrl: 'http://127.0.0.1:PORT' }); // 预检能力 const caps = await client.capabilities(); if (caps.features.session_recap) { const { recap } = await client.recapSession(sessionId); // recap 可能是 null(历史过短或模型瞬时失败),属正常契约 }排查问题:
# 在调试日志中检索 recap 相关记录 grep 'SESSION_RECAP' ~/.qwen/debug/latest.txt小结
Session Recap 是 Qwen Code 中一个"小而完整"的功能切片:三条触发路径收敛到单一核心服务函数,提示词与标签提取保证了输出的形状可控,历史过滤在 token 成本与信息相关性之间取得平衡,hook 状态机与去重门控覆盖了失焦、跨回合、重复触发等全部边界,而"最佳努力"原则通过贯穿 UI 层、服务层与 daemon 层的空值返回与静默失败得到严格贯彻。理解这套设计,不仅有助于熟练使用/recap与自动摘要能力,也能为在 Qwen Code 中接入其他"旁路查询"型 AI 能力(side-query)提供一份可参考的实现范式。
【免费下载链接】qwen-codeAn open-source AI coding agent that lives in your terminal.项目地址: https://gitcode.com/GitHub_Trending/qw/qwen-code
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考