跳到主要内容

核心价值:检索增强的代码补全与 FIM 提示

30 秒导读: Tabby 最核心的对外能力是"在光标处补全代码"。它拿到光标前后的文本(prefix/suffix), 到你的仓库里检索相关片段,把这些片段以注释形式拼进 prefix,再套一个"填空"(fill-in-middle) 模板发给模型。本章讲透这条路径:如何把仓库上下文精确落进一次补全提示。

本章聚焦补全服务本身:请求/响应长什么样、主流程怎么走、FIM 提示怎么拼、检索片段从哪来。 检索与索引的内部实现(tree-sitter 切片、Tantivy、向量+BM25+RRF)只点入口,细节见 检索与索引;token 解码与停止条件见 推理后端


1. 这是什么(零基础也能懂)

一句话定义: 代码补全 = 你在编辑器里打字,光标停在某处,Tabby 猜出"接下来该写什么"并把它补上。

它和普通聊天式 AI 有什么不同? 补全不是"你问它答",而是填空。你的代码天然分成两半:

  • prefix(前缀):光标之前的所有内容;
  • suffix(后缀):光标之后的所有内容。

模型要做的,是把中间那段空缺补出来。这种"给定前后文、填中间"的范式,业界叫 FIM(fill-in-middle,中间填充)

一个直观例子。 你写了个斐波那契函数,光标停在 def fib(n): 的下一行(用 表示光标):

def fib(n):

return fib(n - 1) + fib(n - 2)
  • prefix = "def fib(n):\n "(光标前)
  • suffix = "\n return fib(n - 1) + fib(n - 2)"(光标后)

Tabby 要补的中间部分大概是 if n <= 1:\n return n。这个 prefix/suffix 的例子正是源码 里 CompletionRequest 的 schema 示例(crates/tabby/src/services/completion.rs:34-40)。

"检索增强"又是什么? 光有当前文件的前后文往往不够——你要调的函数、要用的类型,常常在别的文件里。 Tabby 会先去仓库里检索跟当前代码相关的片段(retrieval),把它们塞进提示,让模型"看过邻居再动笔"。 这就是 RAG(Retrieval-Augmented,检索增强) 补全,也是本项目 CompletionService 存在的理由:

CompletionService enhances the CodeGeneration feature by adding Retrieval Augmented Code Completion capability. —— crates/tabby/src/services/completion.rs:283-285

两种补全模式。 Tabby 的补全服务对外支持两种 mode:

mode干什么典型场景
standard(默认)经典 FIM,补全光标处的代码边打字边补
next_edit_suggestion看你刚才的一串改动,预测你下一步要改哪、改成什么"帮我把这个改动同步到其它地方"

mode 字段默认取 "standard"(default_standard_mode,completion.rs:64-70)。


2. 顶层全景(一次补全怎么转)

怎么读这张图: 从上到下是一次 standard 补全请求的生命周期,左边是数据、右边是负责的符号。

HTTP POST /v1/completions
(routes/completions.rs: completions)
│ CompletionRequest { language, segments{prefix,suffix,...}, mode, ... }

┌─────────────────────────────────────────────┐
│ CompletionService::generate │ completion.rs:358
│ │
│ mode == next_edit_suggestion ? ──── 是 ──▶ 走 next-edit 分支(§6)
│ │否 │
│ ① 有 raw_prompt ? ── 是 ──▶ 直接用它当提示,跳过检索
│ │否 │
│ ② build_snippets ──────────────▶ ③ 检索仓库上下文(§5)
│ (收集 RAG 片段) │
│ │ │
│ ④ prompt_builder.build ─────────▶ 拼 FIM 提示(§4)
│ │ │
│ ⑤ CRLF 归一化 (override_prompt) │
└─────────────────────────────────────────────┘
│ prompt: String

engine.generate(prompt, options) ← 推理,见第4章
│ generated_text

⑥ CRLF 还原 + 埋点日志 + 组装 debug_data

CompletionResponse { id, choices:[{text}], mode, debug_data? }

各部件一句话职责:

部件干什么在哪
completions handlerHTTP 入口,取出请求与 AllowedCodeRepositorycrates/tabby/src/routes/completions.rs:26
CompletionService::generate补全总调度:分支、检索、拼提示、埋点completion.rs:358
PromptBuilder收集片段 + 拼 FIM 提示completion/completion_prompt.rs:13
NextEditPromptBuilder拼 next-edit 提示completion/next_edit_prompt.rs:3
CodeGeneration(engine)真正跑模型出 token见第4章
EventLogger把这次补全落成事件日志completion.rs:410

主线走一遍(高层): 请求进来 → 判模式 → (标准模式)检索片段 → 拼 FIM 提示 → 交给引擎生成 → 把生成结果的换行符还原、写日志、按需附上 debug 数据 → 返回。下面逐层拆。


3. 请求与响应的数据模型(API 语义)

这节讲"补全的输入输出长什么样"。理解这几个结构体,后面的流程才不悬空。

3.1 CompletionRequest —— 一次补全请求

CompletionRequest(completion.rs:41-66)是对外 API 的入口结构。关键字段:

字段类型含义
languageOption<String>语言标识(如 python),缺省时后续按 "unknown" 处理
segmentsOption<Segments>光标前后文 + 上下文片段;一旦提供,prompt 就被忽略
userOption<String>终端用户标识,用于监控/报表
debug_optionsOption<DebugOptions>调试开关(见 3.4)
temperature / seedOption<f32> / Option<u64>采样温度 / 随机种子
modeStringstandardnext_edit_suggestion,缺省 standard

请求上挂了几个便捷判定方法,后面流程会反复用到:

  • language_or_unknown()(:86)—— 取语言,没有就 "unknown";
  • raw_prompt()(:91)—— 从 debug_options 里取直接提示(见下);
  • disable_retrieval_augmented_code_completion()(:98)—— 是否关掉检索增强;
  • is_next_edit_suggestion_mode()(:105)—— mode == "next_edit_suggestion"

3.2 Segments —— 光标前后文与上下文

Segments(completion.rs:135-183)是补全上下文的载体。除了 prefix/suffix,它还带了好几路 由编辑器/LSP 预先算好的上下文片段:

字段含义
prefix光标前内容(必填)
suffix光标后内容
filepath当前编辑文件的相对路径
git_url当前 git 仓库的远程 URL;是服务端检索的钥匙(§5)
declarationsLSP 提供的、prefix 里符号的声明片段(优先级最高)
relevant_snippets_from_changed_files最近改过的文件里挑的相关片段,按 score 降序
relevant_snippets_from_recently_opened_files最近打开的文件里挑的相关片段
clipboard请求补全时的剪贴板内容
edit_history next-edit 模式需要,承载编辑历史

注意这里的分工:declarations 和两路 relevant_snippets_*客户端已经算好、内联送进来的 片段;而 git_url 是留给服务端主动检索用的。两者在 §5 汇合。

3.3 Snippet / Declaration —— 上下文片段

Snippet(completion.rs:236-240)是流程内部统一的片段表示,三字段:filepathbodyscore(相关度)。 Declaration(:202-212)更简单,只有 filepathbody——它没有分数,因为声明被当作最高优先级、 天然 score = 1.0(见 §4 的 extract_snippets_from_segments)。

3.4 DebugOptions —— 调试与旁路开关

DebugOptions(completion.rs:111-128)是给"测端到端质量"用的旋钮:

字段作用
raw_prompt直接给一段提示,绕过 segments 和 FIM 拼装,原样喂模型
return_snippets在响应的 debug_data 里回带检索到的片段
return_promptdebug_data 里回带最终拼好的提示
disable_retrieval_augmented_code_completion关掉服务端检索增强

3.5 CompletionResponse —— 响应

CompletionResponse(completion.rs:247-256)含 idchoices(每个 Choice 就是 {index, text})、 可选的 debug_data({snippets?, prompt?},:275-281),以及回显的 mode。响应里 mode 会被显式写成 "standard""next_edit_suggestion",让客户端知道这是哪条路径的产物(:437、:498)。


4. FIM 提示怎么拼(本章核心)

这节是全章的心脏:把仓库上下文精确落进一次补全提示,靠的就是 PromptBuilder

4.1 直觉:FIM 就是"带模板的填空"

不同模型对"填空"有不同的特殊标记(sentinel token)。比如 CodeLlama 用 <PRE> … <SUF> … <MID>。 Tabby 不写死,而是把模板做成可配置字符串,里面留 {prefix} / {suffix} 两个占位符,运行时用 strfmt(字符串模板格式化库)填进去。

原理演示(示意,非源码):

# 一个 FIM 模板长这样,{prefix}/{suffix} 是占位符
template = "<PRE> {prefix} <SUF>{suffix} <MID>"

# 填空:把光标前后文塞进去
prompt = template.format(prefix="def fib(n):", suffix="}")
# => "<PRE> def fib(n): <SUF>} <MID>"
# 模型看到 <MID> 就知道:该在这儿把中间补出来

真实实现就是一行 strfmt!:

strfmt!(prompt_template, prefix => prefix, suffix => suffix).unwrap() —— completion/completion_prompt.rs:32-38,PromptBuilder::build_prompt

如果没配模板(prompt_templateNone),build_prompt 直接返回 prefix、丢掉 suffix(:33-35)—— 退化成"纯前缀续写"。也正因为模板是补全的必需品,加载时若没有模板会直接 panic:

.unwrap_or_else(|| panic!("Prompt template is required for code completion")) —— completion.rs:559,create_completion_service_and_chat

4.2 build 的两步:先重写 prefix,再套模板

PromptBuilder::build(completion_prompt.rs:88-92)只有两步:

build(language, segments, snippets)

├─ rewrite_with_snippets ── 把检索片段以注释塞进 prefix

└─ build_prompt(prefix, get_default_suffix(suffix)) ── 套 FIM 模板

get_default_suffix(:94-98)有个小细节:suffix 为空或 None 时,兜底成 "\n"。这样模板里的 {suffix} 永远不为空,避免模型在"完全没有后文"时行为异常(测试 test_prompt_template<SUF>\n <MID> 用例印证了这点,:358-401)。

4.3 关键手法:把片段变成"注释"塞进 prefix

这是 Tabby FIM 最巧的一招。检索到的跨文件片段不另开字段、也不塞进 suffix,而是逐行加上 该语言的行注释符,拼在 prefix 最前面。模型读到的仿佛是"当前文件顶部本来就有这些参考注释"。

build_prefix(completion_prompt.rs:109-143)的逻辑:

  1. 取该语言的行注释符 get_language(language).line_comment;取不到就放弃注入、原样返回 prefix(:114-116)——所以没有行注释语法的语言不会被注入;
  2. 每个片段先写一行 Path: <filepath>,再逐行铺 body,片段之间空一行(:120-129);
  3. 把上面每一行都加上 注释符 + 空格 前缀(空行只放注释符),最后拼到 prefix 前(:131-142)。

真实测试 test_build_prefix_readable(:461-505)展示了 Python(#)下的产物:

# Path: a1.py
# res_1 = invoke_function_1(n)
#
# Path: a2.py
# res_2 = invoke_function_2(n)
#
# Path: a3.py
# res_3 = invoke_function_3(n)
'''
Use some invoke_function to do some job.
'''
def this_is_prefix():

注意最后一段 '''…'''\ndef this_is_prefix() 是原来的 prefix,注入的片段整整齐齐顶在它上面。 rewrite_with_snippets(:100-107)在 snippets 为空时直接返回原 segments,不做任何改动。

4.4 字符配额:片段不能无限塞

塞进提示的上下文有字符预算,collect(completion_prompt.rs:40-86)开头两个常量定了规矩:

常量含义
max_snippets_chars_in_prompt768所有注入片段的总字符预算(会随消耗递减)
quota_threshold_for_snippets_from_code_search256触发服务端检索的门槛

分配顺序是"先内联、后检索":

预算 = 768

① extract_snippets_from_segments ← 先花在客户端内联片段上
│ 预算 -= 已用字符

② 剩余预算 <= 256 ? ── 是 ──▶ 不再检索,直接返回已有片段
│ 否
③ 用剩余预算去做服务端代码检索 collect_snippets

这个门槛的意思是:如果内联片段已经吃掉了预算、只剩不到 256 字符,那点空间"不值得"再发起一次 代码检索,干脆省掉(completion_prompt.rs:57-59)。

4.5 内联片段的优先级与去重

extract_snippets_from_segments(completion_prompt.rs:145-204)按固定优先级依次装片段, 每装一个就检查会不会超预算,超了就 break:

  1. declarations —— 最高优先级(:153),score 记为 1.0;
  2. relevant_snippets_from_changed_files —— 最近改过的文件(:168);
  3. relevant_snippets_from_recently_opened_files —— 最近打开的文件(:184)。

去重不在这里做——Segments 的文档注释说明,这几路片段在送进来之前客户端已经互相去重过了 (completion.rs:160-176)。服务端只负责按优先级和预算截断。


5. RAG 片段从哪来(检索的入口)

上一节 §4.5 讲的是"客户端内联送来的片段"。这节讲另一路:服务端主动检索。两路最终都汇进 collect 返回的 Vec<Snippet>

5.1 两个来源

来源谁算的入口
内联片段客户端/LSP 预先算好,放在 Segmentsextract_snippets_from_segments
代码检索片段服务端按 prefix 去仓库索引里搜collect_snippets

5.2 检索需要过的四道关

服务端检索不是无条件发生的,collect(completion_prompt.rs:57-73)要连过四关,任何一关 不满足就直接返回已有片段:

剩余预算 > 256 ? (§4.4 的门槛)
└─ self.code 存在? (配了代码检索后端)
└─ segments.git_url 有值? (客户端报了仓库地址)
└─ closest_match(git_url) 命中 source_id? (服务器索引过这个仓库)
└─▶ collect_snippets(...) 真正检索

5.3 git_url → source_id 的匹配

关键一步是把客户端报的 git_url 映射到服务端某个已索引仓库的 source_id。这由 AllowedCodeRepository::closest_match(crates/tabby-common/src/axum.rs:62-81)完成:

  • parse_git_url 解析出仓库名字(name);
  • 在允许的仓库列表里筛出同名的;
  • 多个同名时,按 canonical_git_url() 取字母序最小的一个;
  • 返回它的 source_id

也就是说,匹配靠的是仓库名而非完整 URL 逐字相等——这样 https://git@ 等不同写法的 同一个仓库也能对上。

5.4 collect_snippets:发起检索、按分收片段

拿到 source_id 后,collect_snippets(completion_prompt.rs:206-264)构造 CodeSearchQuery (filepath / language / prefix 作为查询内容 / source_id;code.rs:68,filepath 会归一成 unix 风格), 调 code.search_in_language(...) 检索,然后按字符预算逐条收 hit,每条片段的 scorehit.scores.rrf(:259)——即检索侧的 RRF(Reciprocal Rank Fusion,倒数排名融合) 分数。

一个健壮性细节:检索后端尚未就绪(CodeSearchError::NotReady)时,返回空片段而非报错 (:229-231)——索引还没建好时,补全依然能工作,只是暂时没有跨文件上下文。其它检索错误则记 warn 日志后返回空(:233-244)。

检索用到的 CodeSearchParams 默认阈值(code.rs:98-105:min_embedding_score 0.75min_bm25_score 8.0min_rrf_score 0.028num_to_return 20num_to_score 40)以及向量+BM25+RRF 的融合细节,属于检索子系统,见 检索与索引


6. next-edit-suggestion 模式

standard 补的是"光标处的空缺";next_edit_suggestion 换了个问题:给定你刚才的一串编辑, 下一步你会改什么?

6.1 触发与提示拼装

generate 一进门就判模式,是 next-edit 就转 generate_next_edit_suggestion (completion.rs:367-371、:441)。这条路径不做检索、不套 FIM 模板,而是要求 Segments 里带 edit_history(缺了就 EmptyPrompt 报错,:453-456)。

EditHistory(completion.rs:74-82)三字段:original_code(原始代码)、edits_diff(所有编辑的 统一 diff)、current_version(改完后的当前版本)。

NextEditPromptBuilder::build_prompt(completion/next_edit_prompt.rs:10-18)把它们拼成一个 带特殊标记的提示,末尾留 <|next_version|>\n 让模型接着往下写:

<|original_code|>
<原始代码>
<|edits_diff|>
<统一 diff>
<|current_version|>
<当前版本>
<|next_version|>

模型的任务就是产出 <|next_version|> 之后的内容——即"把这串改动顺理成章地推进一步"后的代码。

6.2 与标准模式的差异

维度standardnext_edit_suggestion
提示范式FIM 模板(prefix/suffix)编辑历史模板(diff)
检索增强有(§5)
解码 token 预算max_decoding_tokens×2(completion.rs:465)
埋点里的 segments不带(None,:477)
响应 mode"standard""next_edit_suggestion"

解码预算翻倍,是因为 next-edit 往往要输出一整段新版本,比补个空缺长得多。


7. 收尾细节:CRLF、埋点、debug_data

standard 分支收尾时还有三件容易忽略但重要的事(completion.rs:382-438)。

7.1 CRLF 归一化(Windows 换行的坑)

Windows 编辑器的换行是 \r\n,但模型基本在 \n 语料上训练。Tabby 的做法是进出各转一次:

  • 进模型前:若 segments 含 \r\n(contains_crlf,:503),把提示里的 \r\n 全换成 \n (override_prompt,:516);
  • 出模型后:再把生成文本里的 \n 换回 \r\n(override_generated_text,:529),让补全结果和 用户文件的换行风格一致。

还原时不能傻替换——文本里可能已有 \r\n(其中的 \n 不该再加 \r)。所以用正则 ([^\r])\n 只匹配"前面不是 \r\n"(:531),避免变成 \r\r\n

7.2 埋点日志

每次补全(两种模式都是)都会 logger.log 一条 Event::Completion(:410、:471),记下 completion_id、语言、最终 prompt、segments(next-edit 为 None)、生成的 choicesuser_agent。 这是后续做补全质量分析、数据回流的基础。

7.3 debug_data 按需组装

只有请求带了 debug_options 才组装 debug_data(:425-431):return_snippets 决定是否回带片段, return_prompt 决定是否回带最终提示。then_some 让"开关关"时对应字段为 None,配合结构体上的 skip_serializing_if = "Option::is_none"(:276、:279)在 JSON 里干脆不出现。


8. 巧妙之处(可借鉴)

  • 上下文即注释。 跨文件片段不新开输入通道,而是加行注释符塞进 prefix 顶部,复用模型"读注释" 的既有能力,零训练成本。build_prefix,completion_prompt.rs:109
  • 字符配额 + 检索门槛。 先花在客户端内联片段、剩余不足 256 字符就不再检索——用一个常量 避免"为一点空间白跑一次检索"。collect,completion_prompt.rs:46-59
  • 模板与模型解耦。 FIM 特殊标记全部收进可配置的 prompt_template + strfmt,换模型只换模板字符串。 build_prompt,completion_prompt.rs:32
  • 索引没就绪也能补。 CodeSearchError::NotReady 返回空片段而非报错,补全优雅降级为"无跨文件上下文"。 collect_snippets,completion_prompt.rs:229
  • CRLF 双向归一 + 正则精修。([^\r])\n 避免把已有的 \r\n 变成 \r\r\noverride_generated_text,completion.rs:529

9. 边界与局限

  • 无行注释语法的语言拿不到注入上下文:build_prefix 取不到 line_comment 就跳过注入 (completion_prompt.rs:114-116)。
  • 服务端检索需要四个条件同时满足(预算、后端、git_urlsource_id 命中),缺一即退回内联片段(§5.2)。
  • raw_prompt 完全绕过检索与 FIM:调试直投用,不代表正常补全路径(completion.rs:383-384)。
  • git_url 靠仓库名匹配:不同仓库若重名可能匹配到非预期的 source_id(axum.rs:73-80)。
  • 去重不在服务端做:内联片段的去重责任在客户端(completion.rs:160-176)。

10. 代码地图(导航索引)

主题文件路径符号名
请求结构与便捷判定crates/tabby/src/services/completion.rsCompletionRequest / is_next_edit_suggestion_mode
上下文载体crates/tabby/src/services/completion.rsSegments / Snippet / Declaration
调试开关crates/tabby/src/services/completion.rsDebugOptions / DebugData
响应结构crates/tabby/src/services/completion.rsCompletionResponse / Choice
补全总调度crates/tabby/src/services/completion.rsCompletionService::generate
next-edit 分支crates/tabby/src/services/completion.rsgenerate_next_edit_suggestion
CRLF 归一化crates/tabby/src/services/completion.rscontains_crlf / override_prompt / override_generated_text
服务装配(需模板)crates/tabby/src/services/completion.rscreate_completion_service_and_chat
片段收集与配额crates/tabby/src/services/completion/completion_prompt.rsPromptBuilder::collect
FIM 模板填充crates/tabby/src/services/completion/completion_prompt.rsPromptBuilder::build / build_prompt
片段注入 prefixcrates/tabby/src/services/completion/completion_prompt.rsbuild_prefix / rewrite_with_snippets
内联片段优先级crates/tabby/src/services/completion/completion_prompt.rsextract_snippets_from_segments
代码检索片段crates/tabby/src/services/completion/completion_prompt.rscollect_snippets
next-edit 提示crates/tabby/src/services/completion/next_edit_prompt.rsNextEditPromptBuilder::build_prompt
git_url→source_idcrates/tabby-common/src/axum.rsAllowedCodeRepository::closest_match
检索查询/参数crates/tabby-common/src/api/code.rsCodeSearchQuery / CodeSearchParams
HTTP 入口crates/tabby/src/routes/completions.rscompletions

上一章: 服务骨架:从 CLI 到 axum 路由装配 · 下一章: 仓库上下文:tree-sitter 切片、Tantivy 索引与 RRF 混合检索 · 返回: Tabby 全景与阅读地图