
把 Claude Code 的源码从外到内翻过一轮之后我印象最深的不是模型调用封装也不是终端 UI 渲染而是 PromptSuggestion 这个模块。模块一第五节把“智能提示”和“推测执行”绑在一起讲初看只是给用户补几个命令深入进去才发现它其实是一个典型的“用工程手段换模型体验”的样例。简单说PromptSuggestion 会在你输入、停顿、或者刚发出指令的间隙提前把最可能的提示文本算出来并且让候选中“看起来最确定”的那一条先进入执行流程等用户确认之后立刻生效省掉一段肉眼可见的等待时间。这篇文章就按我在实际项目里梳理出来的逻辑把候选生成、排序、缓存、推测执行、回滚这一整条链路尽量讲透适合所有做 AI 工具、做提示词工程、或者单纯对源码设计感兴趣的开发者。1. 项目背景与模块定位1.1 PromptSuggestion 到底在解决什么问题先聊一个非常真实的用户痛点很多终端类 AI 工具交互上最大的问题不是“答得不对”而是“等得太久”。模型推理要时间工具调用要时间用户看到反馈之前得盯着闪烁的光标干等。如果只是等用户还能忍真正让人烦躁的是明明下一步要做什么已经很清楚了工具却还是要从头开始重新理解一遍。PromptSuggestion 解决的就是这个“已经清楚却还要等”的尴尬。它把“用户接下来大概率想做什么”这件事提前做了。具体拆开来看有三个核心目标降低交互延迟用户还没敲完指令后台已经把候选结果准备好确认即用。减少无效输入让用户从“自己拼命令”变成“从建议里选”省脑力也省键盘。提升执行确定性如果系统已经推测出用户意图执行时能带上更精确的参数避免二次澄清。这三个目标听起来都不复杂但放到源码里落地的时候处处都是取舍。比如“提前准备”和“资源浪费”就是一对天然矛盾“候选变多”和“用户看得眼花”又是一对矛盾。所以模块的职责边界划定得非常关键它既要管“猜你想做什么”也要管“猜错了怎么收场”。1.2 模块边界提示从哪里来、到哪里去看源码第一步是画边界。PromptSuggestion 不是一个孤立的函数它处在交互层和执行层中间上下游关系非常清晰。输入侧它要拿到的信息包括当前终端的输入缓冲、光标位置、最近的会话历史、当前工作目录、文件树的相关片段、最近几条命令执行结果。这些信息会被整理成所谓的“上下文快照”。注意这里不会把整个终端历史都丢给模型那样既贵又慢。它更像做摘要只提取和“当前输入前缀”“最近操作对象”相关的特征。输出侧它对接三个地方用户界面上的候选列表、真正执行命令的执行引擎、以及日志与监控系统。候选列表负责展示执行引擎负责跑“被选中的那一条”日志系统则把“提示命中了没有”“推测执行消耗了多少资源”这些信息记录下来。边界上很关键的一点是PromptSuggestion 本身不直接执行命令它只负责“解释意图”和“决定是否值得预执行”。真正的执行动作永远由执行引擎来做这样即使推测错了也不会污染主流程。我在很多类似的工具里见过反面教材提示模块自己偷偷跑了命令结果把状态弄脏了用户一确认反而执行了错误的东西。所以源码里把“生成提示”和“执行命令”分成两个模块不是设计过度而是安全底线。1.3 为什么把提示和推测执行放在同一节标题里“智能提示与推测执行”之所以被放在一起不是因为它们名字好听而是因为它们在数据上可以共享很多东西。候选生成的结果本质上是一组带分数的“意图假设”。推测执行要做的是从这些假设里挑一个最像的提前跑起来。没有候选生成推测执行就没有输入没有推测执行候选生成就只停留在“展示层”发挥不出省时间的价值。两者共享同一份上下文快照、同一套评分逻辑甚至共用同一个缓存池。放在同一节来剖析还有一个好处能清楚地看到“意图预测”和“动作预执行”之间的层级关系。前者是后者的充分条件但不是必要条件。源码里会先判断候选是否足够可靠再决定要不要启动推测执行。所以理解提示生成是理解推测执行的前置课理解推测执行反过来也能帮你优化提示生成的评分权重——因为最终要的是“被用户选中并成功执行”的提示而不是单纯看起来合理的提示。2. 提示候选集是怎么生成的2.1 输入快照与特征提取生成候选的第一步是采集一个足够好的上下文快照。我把它理解为“给当前用户状态拍一张照片”。这张照片既不能太模糊也不能太重。太模糊的意思是只拿当前输入框里那几个字符去猜。这样猜出来的结果基本都是通用命令和用户真正的工作场景毫无关系。太重则是把整份工作区文件内容、全部 shell 历史、几百轮对话全部塞进上下文。先不说模型吃不吃得下光序列化成本和 token 费用就够喝一壶了。实际项目中快照通常包含几类特征局部输入特征当前输入的完整命令前缀、光标前一个 token、正在编辑的选项参数。会话级特征最近 3 到 5 轮用户消息、最近一次工具返回结果、当前对话目标的一句话摘要。环境特征当前工作目录名、最近修改过的文件列表、文件类型分布、最近执行过的几条命令。这些特征会被拼成一个结构化的数据对象而不是简单的一串文本。因为后续要做前缀匹配、相似度打分、模型生成不同的算法需要不同的字段。源码里我比较喜欢的一点是它会把这些特征各自带上权重比如“当前输入前缀”权重最高“历史命令”次之“文件类型”再次。这样即使模型生成结果偏离了规则层也能拉住一部分分数。2.2 候选来源规则模板、历史命中、模型生成PromptSuggestion 的候选不是只靠模型怼出来的它至少有三个来源按成本从低到高排列规则模板是响应最快的一层。比如用户输入git时模板库会立刻给出git status、git diff、git log这类候选。再比如用户输入npm run模板库会根据当前项目的 package.json 里的 scripts 字段给出对应命令。这一层没有模型调用只有模式匹配所以耗时基本可以忽略不计。它的缺点是覆盖不了长尾场景只能处理高频模式。历史命中是第二层。它会去检索当前会话、过去相似项目、甚至所有历史会话里出现过的“相同前缀 相似环境”的命令组合。这里有个很有意思的设计不是简简单单地按字符串搜索而是会把命令、工作目录、文件类型、上一轮操作意图组成一个向量或者签名做相似度检索。比如你上次在某个目录下先改了配置文件、再执行了测试命令这次场景相似历史命中就会把“测试命令”推到候选列表前面。模型生成是表达力最强、成本也最高的一层。它把上下文快照转换成一段提示词让模型生成多个可能的下一步动作。为了实现“多个候选”源码里通常会控制采样参数比如让 temperature 略高一点、返回多个candidate。这一层的价值在于它能把“规则没见过、历史没出现过的场景”也覆盖到。但它的延迟和 token 消耗都不低所以通常不会每个按键都触发而是等用户停顿时才触发。三层来源的候选最终会进入同一个排序管道但它们的初始分数会带不同的来源偏置规则模板因为确定性最高初始分给得比较足历史命中其次模型生成不确定性最高初始分最低。这个偏置不是拍脑袋定的而是从线上日志里统计出来的“不同来源候选被用户选中的概率”。2.3 排序去重与截断从几十条到三条候选集刚生成的时候可能会有一大堆几十条都很正常。但 UI 上不可能把几十条都展示出来用户也看不过来。所以排序、去重、截断是必经之路。先说排序。最终的分数不是简单的模型概率而是多层分数的融合前缀匹配分候选前缀和当前输入的重合度越高分越高。场景相似分候选对应的历史场景和当前上下文快照有多相似。模型置信分模型生成时附带的概率值或者 logprob。新鲜度最近用得越多的候选分越高但太久远的要衰减。这里有一个我特别想强调的细节排序不是静态的。如果用户继续输入了字符比如从git变成了git che那么前缀匹配分会被重新计算候选顺序可能立刻变化。所以源码里不会在第一次生成后就完全锁死结果而是在按键事件触发时做增量更新。增量更新比起重新生成要便宜得多大部分候选保持原样只重新打分。去重也很重要。模型生成和历史命中有时候会给出一模一样的命令字符串层面完全相同。源码里会先做归一化把多余空格、结尾斜杠、大小写统一掉然后保留分数最高的那条。有些版本还会做“语义去重”比如git status -s和git status --short语义相同会被折叠成一条。截断策略则要平衡覆盖率和可读性。一般来说UI 上展示 3 到 5 条候选就够了但内部保留的候选集可以略大比如 8 条用于推测执行时做备选。截断时还有一个 token 预算的概念所有候选加起来的预估 token 数不能超过某个上限否则光是展示候选列表就会占掉大量内存和 UI 渲染时间。截断之后候选集就进入“可展示、可执行”的状态了。3. 推测执行让下一步先跑起来3.1 推测执行到底在“猜”什么推测执行这个名字听起来很高大上但本质可以用一个生活化类比讲清楚餐厅后厨的备菜。客人还没点单后厨已经根据客人常点菜和当前排队情况把几道最可能的菜备好半成品。客人一下单厨师只需要最后翻炒两下就能上菜。如果客人点了别的菜备好的半成品就浪费了但这种浪费是可控的、可接受的。在 Claude Code 这类工具里推测执行面对的问题是用户已经选中或者悬停在某个候选上系统要不要提前启动对应的命令如果启动用户确认之后就能立刻看到结果但万一用户反悔后台执行的进度就要被丢弃。源码里对“猜”的时机有很严格的定义。不是任何候选都值得推测执行至少要满足几个条件候选的分数要高于一个阈值也就是“系统要有把握”。候选对应的命令要是相对安全的比如只读命令、打印帮助、生成预览。当前不能已经有另一个推测执行在跑避免资源竞争。所以推测执行的核心思想不是“无脑提前跑”而是“在风险可控的前提下尽可能用空闲时间换确认后的响应速度”。3.2 命中、丢弃与回滚推测执行一旦启动就会进入一个可跟踪的状态。我把这里拆成三种结局命中、未命中、冲突。命中是最理想的用户确认了候选系统直接把后台已经跑出来的结果接过来展示用户体感就是“秒出”。为了做到这一点推测执行不仅要缓存“命令执行结果”还要缓存“命令执行到一半的中间状态”。比如一个命令需要先读取文件、再调用模型、最后输出报告推测执行会把前面几步的结果都缓存下来用户确认后就从最后一步继续。未命中是更常见的情况用户没有选那个候选而是自己敲了完全不同的命令。这时系统要把推测执行产生的所有临时产物作废包括临时文件、环境变量修改、内存缓存。注意这里的“作废”不是简单删除而是要回到推测执行之前的状态。所以源码里会维护一个 undo 日志记录推测执行过程中每一步做了什么类型的改动。冲突则是推测执行还在跑用户又触发了另一个候选。这种情况下后发起的推测执行有更高优先级先前的执行会被中断并回滚。做过终端工具的人都知道中断一个后台任务比启动一个任务更难因为它可能已经在做网络请求或者写文件了。源码里对这种场景的处理方式是推测执行只允许运行“声明为可中断”的任务凡是标记了allowInterrupt: false的任务不管分数多高都不会进入推测执行。3.3 并行消费与流式输出配合推测执行最容易被忽略的技术难点是它和流式输出之间的配合。用户还没确认时推测执行的后台输出不能直接打到终端上否则会造成视觉错乱。所以输出会先进入一个缓冲区等确认那一刻缓冲区里的内容才“接上”用户界面。这个缓冲区在源码里是一个很典型的生产者消费者模型后台任务负责往缓冲区写 tokenUI 线程负责在确认后从缓冲区读 token。为了避免重复输出缓冲区还会记录一个偏移量表示用户已经看到哪个位置了。如果用户在中途确认不需要从头显示而是从当前偏移量继续。并行消费还意味着候选生成、推测执行、正常输入这三个流程可以同时存在。比如用户正在输入一个很长的命令系统后台可以一边生成候选一边对某个高分候选做推测执行。两者互不阻塞靠事件循环和异步任务来调度。源码里如果发现某一块同步耗时超过几十毫秒基本都会被拆成异步流程目的就是保证 UI 不卡顿。4. 关键代码路径与数据结构4.1 核心数据结构和状态机为了把上面的逻辑固定下来源码里通常会定义几个核心类型。我这里用 TypeScript 风格的伪代码还原一下不是复制源码而是把关键字段抽出来interface PromptSuggestion { id: string; text: string; score: number; source: template | history | model; meta: { reason: string; tokenEstimate: number; canSpeculate: boolean; }; } interface ContextSnapshot { inputPrefix: string; recentMessages: Array{ role: string; content: string }; cwd: string; recentFiles: string[]; lastCommands: string[]; } interface SpeculativeSession { id: string; suggestionId: string; status: idle | warming | ready | committed | discarded; undoLog: Array() Promisevoid; outputBuffer: string[]; aborted: boolean; }可以看到PromptSuggestion 不仅仅携带文本本身还携带了canSpeculate标志。这个标志决定了它能不能进入推测执行。tokenEstimate用于控制展示层和缓存预算。reason字段非常有用它记录了这个候选是怎么来的方便调试时定位问题。推测执行的状态机则是一个标准的状态转移图idle表示还没开始warming表示正在准备数据ready表示已经跑完可以交付committed表示用户确认、结果正式生效discarded表示被丢弃。这个状态机是所有回滚逻辑的基石。4.2 主流程伪代码主流程可以抽象成一个主函数suggestAndMaybeRun。我用伪代码展示最核心的链路async function suggestAndMaybeRun(input: string) { const snapshot await collectContextSnapshot(input); // 1. 并行收集候选而不是串行 const [templateCandidates, historyCandidates, modelCandidates] await Promise.all([ generateFromTemplates(snapshot), generateFromHistory(snapshot), generateFromModel(snapshot) ]); // 2. 统一排序和去重 const ranked rankAndDedupe( [...templateCandidates, ...historyCandidates, ...modelCandidates], snapshot ); // 展示候选 renderCandidates(ranked); // 3. 只对最高分且安全的候选做推测执行 const top ranked[0]; if (top?.meta.canSpeculate shouldSpeculate(top, snapshot)) { const session beginSpeculative(top); try { const confirmed await waitForUserConfirmation(); if (confirmed) { await commitSpeculative(session); } else { await discardSpeculative(session); } } catch (err) { await discardSpeculative(session); } } }这里有几个耐人寻味的设计决策。第一三类候选是并行生成的尤其模型生成耗时最长不要让模板和历史等它。第二shouldSpeculate单独抽出来意味着即使候选分数很高也可能因为当前电量、网络、资源占用等因素被拒绝推测。第三commitSpeculative和discardSpeculative都放在同一个 try/catch 里说明回滚和提交同样重要出错了宁可直接丢弃也不能留下脏状态。4.3 并发控制与超时推测执行虽然是“提前干活”但不是无限制地干活。源码里通常会有两个控制点超时和并发上限。超时一般用AbortController或者类似的信号机制实现。比如后台任务超过 2 秒还没跑完就没有继续等的必要了因为用户早就自己动手输入了。超时之后要触发abort并让推测执行进入discarded状态。这里要注意不是所有任务都能安全 abort 的。如果任务正在写文件硬中断可能留下半个文件。所以真正实现时abort 会先尝试合作式取消也就是设置一个标志位让任务自己在安全点退出。并发上限通常设置为 1。也就是说同一时间只允许有一个推测执行在跑。原因很简单如果同时跑两个猜测用户确认了其中一个另一个就得回滚浪费的资源会翻倍。与其这样不如把资源集中给最可能的那个候选。这个控制通常用一个简单的互斥锁或者信号量实现在beginSpeculative时加锁在commit或discard时释放锁。5. 调优与二次开发5.1 候选质量的三个旋钮做集成或者二次开发的时候最关心的是怎么让候选质量符合自己的场景。根据我的经验值得优先调的三个旋钮是采样温度、候选条数和上下文裁剪长度。采样温度温度越高模型生成的候选越多样但偏离用户意图的概率也越大。做提示建议时温度通常要比正常对话低一点因为用户要的是“高概率的下一步”不是“有创意的下一步”。候选条数内部保留的候选条数越多覆盖的场景越全但排序噪声也越大。建议 UI 展示 3 条内部保留 8 条左右。上下文裁剪长度上下文快照不是越长越好。太长了模型反而抓不到重点。我实际测试下来把最近 5 轮消息和最近 5 个文件作为上限已经能覆盖绝大多数场景再往上加准确率提升很小但延迟和 token 成本上升明显。下面的表格是我在本地做的一组粗略对照不同环境略有差异但趋势很稳定参数组合候选命中率平均生成延迟备注低温度 3 条候选 短上下文中等低稳定但偏保守中温度 6 条候选 中上下文高中最推荐高温度 10 条候选 长上下文低高发散严重不建议所以不要一上来就追求“模型多聪明”先把候选条数和上下文裁剪调到合理区间效果往往比自己想得更明显。5.2 日志与指标观测做这类模块日志不是拿来排障的而是拿来量化体验的。我会重点盯几个字段suggestion_id每次候选的唯一标识用来关联命中情况。source候选来自规则、历史还是模型用来评估不同来源的贡献。score候选分数用来验证评分权重是否合理。confirmed用户是否选中了该候选这是最重要的结果指标。speculate_hit推测执行是否被用户确认。latency_ms从用户输入到候选展示、从确认到结果展示的延迟。通过这些指标可以定义一个“提示有效率”被用户确认的候选数 / 展示的候选总数。如果这个值低于 20%说明候选质量太差要先查上下文快照是不是缺了关键信息如果高于 60%说明候选太保守用户在把它当命令补全工具在用而不是当智能助手。这就进入了一个很微妙的平衡既要让用户觉得“它懂我”又要让用户觉得“它有新东西”。5.3 本地扩展点大多数类似工具的源码都会留一些扩展点PromptSuggestion 也不例外。最常见的扩展方式是注册一个新的CandidateSourceclass MyCustomSource implements CandidateSource { async generate(snapshot: ContextSnapshot): PromisePromptSuggestion[] { // 读取内部知识库生成特定领域命令 return suggestions; } }自定义来源的应用场景很多比如公司内部有一套部署工具可以直接根据当前项目名提示对应的部署命令或者运维场景里根据当前服务器负载提示重启或扩容命令。这些场景通用模型很难覆盖但通过扩展候选来源可以做得又快又准。另一个扩展点是自定义排序函数可以调整不同来源候选的权重让特定业务的关键词更容易排到前面。6. 常见问题与排查实录6.1 提示候选总是太保守怎么办现象用户明明在一个 Python 项目里候选却总是给出ls、cd、git status这类通用命令。排查下来发现历史命中这一层的权重太高导致通用命令反复出现模型生成的新颖候选被挤到了后面。解法有两个方向一是降低历史命中的初始分让模型生成的候选有更多露脸机会二是给上下文快照增加“项目特征”的权重比如当前目录里有pyproject.toml时给 Python 相关命令加分。我试过之后候选列表明显更贴合当前项目。最怕的是只调温度温度调太高候选是多样了但完全不靠谱。6.2 推测执行增加了无用消耗现象后台推测执行频繁启动但用户大多数时候根本不确认导致 CPU、token、时间全被浪费。查日志发现speculate_hit非常低但suggested很高。这个问题要从触发条件入手。不要对所有高分候选都做推测执行要加一个“用户停留时间”的判定比如用户在某个候选上停留超过 300 毫秒才启动。另外只对canSpeculate的高置信候选做推测而不是对前 3 名都做。我之前见过一个实现把 3 个候选并行推测结果三个全没被选中资源白白烧掉后来改成最多 1 个浪费立减。6.3 长上下文下首字延迟飙升现象用户聊了很多轮之后候选生成越来越慢连规则模板的响应都变慢了。原因是上下文快照变得很大即使规则模板只需要前缀但快照序列化和传递成本被整体拖高了。解法是给快照做分层轻量级快照只包含输入前缀和当前目录用于规则模板重量级快照才包含完整会话用于模型生成。规则模板走轻量级快照延迟能立刻降下来。另一个办法是给模型生成加增量缓存复用已经编码过的历史上下文而不是每次重新编码。源码里如果没做这块二次开发时可以优先补上。6.4 回滚不干净出现幽灵副作用现象推测执行被丢弃后用户还是能看到一些残留效果比如环境变量变了、某个临时文件没有删干净、后台进程还在跑。这是因为回滚日志只覆盖了文件系统没有覆盖环境变量和子进程。解决思路是扩展 undo 日志的类型不只是一个函数数组而是一个结构化的操作列表。每次操作要说明自己是“文件类”“环境变量类”还是“进程类”回滚时按类型处理。还要严格遵守一条红线推测执行只能跑那些可以完整记录 undo 信息的操作。如果一个命令的副作用无法枚举就不应该进入推测执行。这条红线比任何优化都重要。问题可能原因处理建议候选太保守历史权重过高、温度过低调权重、降上下文截断推测执行浪费资源触发条件太松增加停留时间判定、限制并发为 1长上下文首字延迟高快照太重、无增量缓存分层快照、缓存已编码上下文回滚不干净undo 日志覆盖不完整结构化操作日志、禁止不可逆操作推测7. 我的个人体会与后续可以玩的方向把 PromptSuggestion 这块啃完之后我最深的体会是真正提升 AI 工具体验的往往不是模型本身而是模型外面那一圈工程骨架。候选生成、排序、缓存、推测执行、回滚每一个环节单独拿出来都不算惊艳但组合在一起就能让用户产生一种“这个工具手很快”的直觉。而“手很快”恰恰是用户愿不愿意继续用下去的关键。后续如果想在这个方向上继续玩我觉得有两个延展点很有意思。一个是用强化学习或者在线学习来优化排序权重让评分函数根据用户的确认行为自动调整另一个是把推测执行从“命令级”升级到“工作流级”比如不仅猜到下一条命令还能猜到用户接下来三步要做什么提前把中间产物准备好。如果你正在做类似的终端 AI 工具我的建议是先别急着换更大的模型而是把用户意图的“候选空间”整理清楚再把“猜错了能安全回滚”这条底线守住。这两件事做好体验提升会非常明显。