写在前面

这篇文章源于我与一位开发者朋友(即本文读者)的深度技术讨论。我们从“Claude Code 终端里如何实现上下键切换选项”这样一个具体问题出发,一路聊到了网页端AI选项交互设计、对话上下文的全量传输与压缩、编辑/删除操作对模型的影响,以及编程AI工具(Claude Code、OpenCode)在处理大型项目时的上下文管理策略。整个对话让我受益匪浅,也让我意识到这些知识碎片如果能系统化整理,会对很多开发者有帮助。

因此,我结合对话中的核心观点、技术原理以及实际使用经验,撰写了这篇技术说明文章。内容力求详尽,不仅涵盖我们讨论过的所有要点,还补充了一些背景知识和工程建议,希望能成为你日后查阅和复习的参考资料。


第一部分:终端交互的底层原理——以 Claude Code 为例

1.1 现象:终端里可选择的选项列表

当你使用 Claude Code 或其他终端 TUI 工具时,会看到类似这样的交互:

> Fix lint errors
  Add unit tests
  Update docs

按下 ↑↓ 键,高亮条随之移动,按 Enter 确认。这看起来像是“终端天然支持”的能力,但实际上它完全是程序模拟出来的。

1.2 核心机制:Raw Mode + ANSI Escape Sequences

终端默认处于“行缓冲模式”:用户输入一行后按 Enter,系统才把整行交给程序。要实现对每个按键的即时响应(如 ↑↓ 切换),必须切换到 raw mode(原始模式)。

Step 1:开启 Raw Mode

process.stdin.setRawMode(true);
process.stdin.resume();
process.stdin.setEncoding('utf8');

此时,键盘的每个按键都会立刻触发 data事件,程序可以自行解析。

Step 2:解析按键字节流

箭头键不是单个字符,而是多字节的 ANSI 转义序列:

按键

字节序列

\x1b[A

\x1b[B

Enter

\r\n

Esc

\x1b

程序需要维护一个状态机来正确处理这些序列(尤其是当序列被 TCP 分包时)。

Step 3:重绘终端内容

终端屏幕就像一个可擦写的画板。要更新显示,程序需要:

  • 使用 \x1b[{n}A向上移动 n 行

  • 使用 \x1b[K清除当前行

  • 或者使用 \x1b[{row};{col}H绝对定位光标

  • 重新输出选项列表,用 ANSI 颜色(如 \x1b[36m青色)标记高亮项

1.3 Claude Code 的实际实现

Claude Code 并非手写这些底层操作,而是使用了 Ink​ 框架(React-in-the-Terminal)。Ink 封装了 raw mode 管理、键盘事件解析、虚拟 DOM diff 和终端输出优化。开发者只需像写 React 组件一样写 UI:

import { useInput } from 'ink';

const Selector = ({ items, onSelect }) => {
  const [highlight, setHighlight] = useState(0);
  useInput((input, key) => {
    if (key.upArrow) setHighlight(h => Math.max(0, h-1));
    if (key.downArrow) setHighlight(h => Math.min(items.length-1, h+1));
    if (key.return) onSelect(items[highlight]);
  });
  // 渲染逻辑...
};

此外,Claude Code 还实现了更高级的键盘协议支持(Kitty Keyboard Protocol、xterm modifyOtherKeys),以及 bracketed paste、focus reporting 等特性,并在退出时严谨地恢复终端状态。

1.4 小结

终端交互的“魔法”并不神秘:raw mode + ANSI 转义序列 + 状态机解析 + 增量重绘,这四个要素构成了几乎所有终端 TUI 的基础。理解这一点,你就能在自己的 CLI 工具中实现类似的效果。


第二部分:网页端 AI 选项交互设计

2.1 需求场景

在网页聊天界面中,AI 有时需要提供多个选项供用户选择(例如“你想让我做什么?”),用户点击或键盘选择后,AI 根据选择继续对话。

2.2 数据模型设计(最关键的一步)

不要将选项混在文本中,而是将其作为消息的结构化字段:

type Message = {
  role: 'user' | 'assistant' | 'system';
  content: string;
  choices?: {
    id: string;
    label: string;
    value: string;
    variant?: 'primary' | 'default';
  }[];
  selectedChoice?: string | null; // 用户已选中的 choice.id
};

这样前端可以独立渲染选项组件,后端也可以轻松追踪选择状态。

2.3 三种主流 UI 方案

方案 A:气泡内按钮组(推荐,适用于大多数聊天场景)
  • 选项渲染在 assistant 气泡下方,作为一组 button。

  • 选中后,该组按钮整体 disabled,被选中的按钮高亮。

  • 同时追加一条 user 消息(系统代发)表示选择结果。

优点:移动端友好、鼠标点击自然、不破坏会话滚动。

方案 B:焦点列表(模仿终端风格)
  • 使用 role="listbox"+ aria-activedescendant实现无障碍。

  • 捕获键盘事件 ↑↓/Enter 切换高亮,同时支持点击。

  • 适合强调“命令面板”或“黑客风”的产品。

方案 C:弹出面板(Modal / Drawer)
  • 当选项是强阻断性(不选无法继续)时使用。

  • 选择后关闭面板,结果注入会话。

2.4 选择后的衔接体验

  • 锁定:同一消息只允许选一次,选中后禁用所有选项。

  • 反馈:显示“已选:XXX”的 badge,同时追加一条 user 消息(系统代发)或直接进入下一轮 AI 回复。

  • 流式输出:如果 AI 回复是流式的,选项应在流结束后才出现(或作为独立的 JSON chunk 提前送达)。

2.5 代码骨架(React)

function AssistantBubble({ msg, onSelect }) {
  const chosen = msg.selectedChoice;
  return (
    <div className="bubble assistant">
      <div className="text">{msg.content}</div>
      {msg.choices?.length && (
        <div className={`choices ${chosen ? 'locked' : ''}`}>
          {msg.choices.map(c => (
            <button
              key={c.id}
              disabled={!!chosen}
              className={chosen === c.id ? 'active' : ''}
              onClick={() => onSelect(msg, c)}
            >
              {c.label}
            </button>
          ))}
          {chosen && <span className="badge">已选:{msg.choices.find(c=>c.id===chosen)?.label}</span>}
        </div>
      )}
    </div>
  );
}

第三部分:对话上下文管理——全量传输、压缩与编辑操作

3.1 现状:每次请求都携带完整历史

在典型的 LLM 聊天应用中,前端会将 system prompt + 全部历史消息 + 当前用户输入​ 一起发送给模型。这意味着:

  • 模型本身没有记忆,每次推理都是基于你提供的上下文重新计算。

  • 上下文越长,token 消耗越大、响应越慢、成本越高。

  • 早期的错误或无关信息会持续污染后续生成。

3.2 摘要压缩:解决上下文膨胀的利器

为什么需要压缩?

当对话超过几十轮,即使模型支持 200K 上下文窗口,也会出现 context rot(上下文腐烂)——大量垃圾信息稀释了注意力,模型开始“忘记”早期的关键约束。

四种常见压缩策略

策略

做法

优点

缺点

裁剪(Sliding Window)

只保留最近 N 轮

简单

丢失早期重要信息

固定摘要(Summary Turn)

定期让模型将旧历史压缩成摘要,放入 system 或 memory 块

保留关键事实

摘要可能有损、漂移

检索式(RAG)

将历史分块并向量化,每次只检索相关片段

精准、可扩展

工程复杂

动态重要性回收

根据时间衰减、显式标记、用户回指等打分,决定保留/压缩/丢弃

灵活

实现难度高

工程中最实用的方案:Summary Turn
  • 触发条件:token 达到窗口上限的 80%,或每 N 轮。

  • 压缩对象:较老的连续 M 轮对话。

  • 生成摘要:用一次独立的 LLM 调用(不流式输出),要求模型输出结构化要点(用户目标、已达成结论、约束、待办)。

  • 保留最近 L 轮原文(例如 3-5 轮),确保近期细节不丢失。

  • 将摘要插入 system 或 memory 角色,且永远不压缩 system prompt 和项目规则文件。

注意:摘要会引入信息损失,且可能产生“自我强化”的幻觉。建议在摘要 prompt 中要求“只写可验证的事实/决策/约束”,并定期用原始日志校验。

3.3 编辑/删除操作对上下文的影响

用户常常需要修正自己说错的话或 AI 跑偏的回答。不同产品提供了不同粒度的操作:

操作一:删除某一条消息(如豆包)
  • 用户删除一条 user 消息及其对应的 assistant 回复。

  • 正确的实现:截断该消息及其之后的所有消息(因为后续消息依赖被删的前提),然后让用户重新输入。

  • 错误的实现:只在界面隐藏,但上下文仍保留(欺骗用户)。

操作二:重新编辑最近一条消息(如元宝)
  • 用户编辑最后一条 user 消息,AI 重新生成对应的回复并覆盖。

  • 本质是:删除最后一对 user-assistant,用新内容替换 user,重新请求。

操作三:重新生成(Regenerate)
  • 不修改历史,只踢掉最后一条 assistant 回复,用相同的上下文重新请求。

设计原则
  • 因果链完整性:删除/编辑中间某条消息时,必须同时删除其后所有依赖它的消息,否则上下文会出现“孤儿节点”。

  • UI 提示:告知用户“将删除此消息及后续回复”,避免困惑。

  • 逃生舱三件套:推荐按优先级实现:重新生成 > 编辑最近 > 删除任意条。

3.4 用户中断 AI 回复的处理

当用户在 AI 流式回复中途点击“停止”并发送新消息时,有两种处理方式:

  1. 保留截断的回复:将已收到的 token 作为一条完整的 assistant 消息写入历史。模型会看到一条不完整的回复,可能试图续写或修正。

  2. 丢弃截断的回复:不保存任何已收到的 token,相当于这次回复从未发生。上下文更干净。

推荐做法:丢弃截断的回复,并在 UI 上提示“上一条回复已取消”。对于用户补充输入的情况,可将两次 user 消息合并为一条(或连续两条 user 消息)重新请求。


第四部分:编程 AI 工具(Claude Code / OpenCode)的上下文管理

4.1 挑战:不是对话撑爆窗口,而是工具结果

编程 Agent 与普通聊天最大的不同在于:撑爆上下文的不是对话轮次,而是工具调用的中间产物。例如:

  • Read文件返回的数百行代码

  • Bash命令的输出(编译日志、测试结果)

  • Edit/ Write的补丁记录

  • 多次重试、失败的命令输出

这些 tool results​ 才是 token 消耗的大头。

4.2 Claude Code 的四层压缩策略

Claude Code 在源代码中实现了一套精巧的上下文压缩流水线,按成本从低到高排列:

第 1 层:Micro-compact(零成本,每轮静默运行)

纯规则驱动,不调模型。核心逻辑:按工具类型白名单,保留最近 N 个工具结果,更早的直接删除。同时利用 Anthropic API 的 Prompt Cache 功能,通知服务端“删除这些 tool result 但保留缓存前缀”,避免缓存失效。

第 2 层:Session Memory Compact(低成本,异步子 Agent)

当对话超过约 1 万 token 时,后台启动一个轻量子 Agent,专门负责写一份“会话记忆文件”,记录当前进度、已改文件、遇到的问题。等到需要 Full Compact 时,直接使用这份文件代替旧历史,省去一次 LLM 调用生成摘要的开销。

第 3 层:Full / Auto Compact(高成本,模型生成 9 段摘要)

当 token 接近窗口上限(Claude Code 留了 20K 输出预留 + 13K 触发缓冲,约 167K 触发)时,让模型将历史压缩成结构化 9 段摘要:

  1. 用户原始意图

  2. 核心技术概念

  3. 关注的文件和代码

  4. 遇到的错误及修复方式

  5. 解决问题的逻辑链

  6. 所有用户消息摘要

  7. 待办事项

  8. 当前正在做什么

  9. 建议的下一步

压缩后做 状态重构(rehydrate):自动重新读取最近编辑过的文件(最多 5 个,单文件 ≤5K tokens),确保模型手上有最新代码。同时重新注入工具定义和 CLAUDE.md(从磁盘重读)。

第 4 层:兜底(Reactive Compact + Context Collapse)

如果 API 返回 prompt_too_long,自动触发一次 Reactive 压缩并重试。连续 3 次失败则停止自动压缩,防止死循环。

4.3 OpenCode 的策略(对比)

OpenCode 的上下文管理相对轻量:

  • Prune(修剪):按时间戳“标记隐藏”旧消息,数据可恢复。

  • Summary(摘要):生成五段式摘要,然后自动回放用户最后一条消息,使模型直接从最新指令继续,避免摘要断层感。

  • 无子 Agent:复杂调研需用户手动切会话。

  • 依赖外部记忆:建议用户创建 OPENCODE.md文件来持久化项目状态。

4.4 工程启示:CLAUDE.md 是最好的“外接记忆”

无论是 Claude Code 还是 OpenCode,官方都推荐将项目规则、架构决策、当前进度记录在项目根目录的 CLAUDE.md(或 OPENCODE.md)文件中。这样做的好处:

  • 不受上下文压缩影响(每次会话开始时从磁盘重读)。

  • 新会话或压缩后,Agent 能立刻恢复“项目状态”。

  • 用户可以手动更新,控制 Agent 的行为。

最佳实践:每完成一个子任务,让 Agent 自己更新 CLAUDE.md,勾掉已完成项,写下下一步起点。


第五部分:OpenCode 使用技巧——针对不同场景的实战指南

5.1 改小功能:一个会话只干一件事

  • 原则:一个功能点一个会话,干完就 /clear

  • 技巧:开场直接给出文件路径和关键函数名,避免 Agent 花几轮探索。

  • 验证:改完后让 Agent 跑一次相关测试(npm test -- -t xxx)。

5.2 改项目架构:靠外接记忆和会话拆分

架构级改动(拆模块、迁 ORM、改目录结构)跨文件多、周期长,最容易 context rot。

  • OPENCODE.md:记录当前架构任务、已完成项、进行中项、约束、下次起点。

  • 切分会话粒度

    1. 会话 A:“只列迁移计划,不动代码,输出文件清单和影响面。”

    2. 会话 B:“按计划迁第一个模块。”

    3. 会话 C:“迁第二个模块。”……

  • 每会话结束时更新 OPENCODE.md:勾掉已完成,写下下一步。

5.3 DB 字段加状态 + 前端同步:标准作业程序

这是最容易“改漏”的场景(后端改了但前端筛选项没加)。推荐以下 SOP:

第 1 步:让 Agent 列出影响面(不许动手)

“订单表 orders.status要新增一个状态 pending_approval先别改代码,帮我列:

  1. 涉及哪些文件(migration / model / DTO / 前端类型 / 前端筛选项 / 列表/详情页)

  2. 每个文件里 status 现在怎么定义的(enum?union type?zod?)

  3. 前端筛选项的渲染位置(Select?Badge 映射?)

    列完等我确认再改。”

第 2 步:按“后端 → 前端”顺序改,每步汇报

“按清单改,顺序:migration → prisma schema → 后端 DTO/enum → 前端类型 → 前端筛选项 Select 的 options → 列表页 Badge 映射。每改完一个文件你就告诉我文件名我确认。”

第 3 步:关键检查点

让 Agent 专门查这三处,90% 的漏改在这里:

  • 前端 filter 的 options数组有没有新状态

  • 前端 statusMap/ badgeVariant映射有没有新状态

  • 后端查询过滤里有没有 statusin枚举写死了(例如 WHERE status IN ('pending','paid')

第 4 步:跑迁移 + 类型生成

“改完跑 pnpm prisma migrate dev --name add_pending_approvalpnpm prisma generate,把输出贴我。”

5.4 选择合适的模型

OpenCode 支持多种 provider:

  • 本地模型(如 qwen3-coder、codestral):适合小功能、快速迭代,成本低但推理能力有限。

  • 云端模型(Claude Sonnet、GPT-4o):适合架构级、跨端同步等复杂推理任务。

建议:小功能用本地,大任务切云端,在配置文件中通过 provider字段切换。


结语

回顾整个对话,我们从一个看似简单的终端交互问题出发,逐渐深入到对话系统的核心设计——上下文管理。无论是终端 TUI 的 raw mode 与 ANSI 序列,还是网页端的消息结构与选项交互,再到 LLM 应用的上下文压缩与用户编辑操作,最终指向同一个本质:

好的对话系统设计,不是让模型记住一切,而是帮助模型在有限的窗口内聚焦于最有价值的信息。

对于开发者而言,理解这些原理不仅能让你更好地使用现有工具(如 Claude Code、OpenCode),更能指导你构建自己的 AI 应用。希望这篇文章能成为你日后查阅的“知识地图”,也期待你在实践中发现更多有趣的问题。

如果你有任何疑问或想深入探讨某个具体环节,欢迎随时交流。毕竟,最好的学习方式就是不断提问和分享。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐