数据截至 (上游 commit 21b29048d7bc)
核心价值:检索增强的代码补全与 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 handler | HTTP 入口,取出请求与 AllowedCodeRepository | crates/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 的入口结构。关键字段:
| 字段 | 类型 | 含义 |
|---|---|---|
language | Option<String> | 语言标识(如 python),缺省时后续按 "unknown" 处理 |
segments | Option<Segments> | 光标前后文 + 上下文片段;一旦提供,prompt 就被忽略 |
user | Option<String> | 终端用户标识,用于监控/报表 |
debug_options | Option<DebugOptions> | 调试开关(见 3.4) |
temperature / seed | Option<f32> / Option<u64> | 采样温度 / 随机种子 |
mode | String | standard 或 next_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) |
declarations | LSP 提供的、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)是流程内部统一的片段表示,三字段:filepath、body、score(相关度)。
Declaration(:202-212)更简单,只有 filepath 和 body——它没有分数,因为声明被当作最高优先级、
天然 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_prompt | 在 debug_data 里回带最终拼好的提示 |
disable_retrieval_augmented_code_completion | 关掉服务端检索增强 |
3.5 CompletionResponse —— 响应
CompletionResponse(completion.rs:247-256)含 id、choices(每个 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_template 为 None),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)的逻辑:
- 取该语言的行注释符
get_language(language).line_comment;取不到就放弃注入、原样返回 prefix(:114-116)——所以没有行注释语法的语言不会被注入; - 每个片段先写一行
Path: <filepath>,再逐行铺body,片段之间空一行(:120-129); - 把上面每一行都加上
注释符 + 空格前缀(空行只放注释符),最后拼到 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_prompt | 768 | 所有注入片段的总字符预算(会随消耗递减) |
quota_threshold_for_snippets_from_code_search | 256 | 触发服务端检索的门槛 |
分配顺序是"先内联、后检索":
预算 = 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:
declarations—— 最高优先级(:153),score记为1.0;relevant_snippets_from_changed_files—— 最近改过的文件(:168);relevant_snippets_from_recently_opened_files—— 最近打开的文件(:184)。
去重不在这里做——Segments 的文档注释说明,这几路片段在送进来之前客户端已经互相去重过了
(completion.rs:160-176)。服务端只负责按优先级和预算截断。
5. RAG 片段从哪来(检索的入口)
上一节 §4.5 讲的是"客户端内联送来的片段"。这节讲另一路:服务端主动检索。两路最终都汇进
collect 返回的 Vec<Snippet>。
5.1 两个来源
| 来源 | 谁算的 | 入口 |
|---|---|---|
| 内联片段 | 客户端/LSP 预先算好,放在 Segments 里 | extract_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,每条片段的 score 取
hit.scores.rrf(:259)——即检索侧的 RRF(Reciprocal Rank Fusion,倒数排名融合) 分数。
一个健壮性细节:检索后端尚未就绪(CodeSearchError::NotReady)时,返回空片段而非报错
(:229-231)——索引还没建好时,补全依然能工作,只是暂时没有跨文件上下文。其它检索错误则记
warn 日志后返回空(:233-244)。
检索用到的 CodeSearchParams 默认阈值(code.rs:98-105:min_embedding_score 0.75、
min_bm25_score 8.0、min_rrf_score 0.028、num_to_return 20、num_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 与标准模式的差异
| 维度 | standard | next_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)、生成的 choices、user_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\n。override_generated_text,completion.rs:529。
9. 边界与局限
- 无行注释语法的语言拿不到注入上下文:
build_prefix取不到line_comment就跳过注入 (completion_prompt.rs:114-116)。 - 服务端检索需要四个条件同时满足(预算、后端、
git_url、source_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.rs | CompletionRequest / is_next_edit_suggestion_mode |
| 上下文载体 | crates/tabby/src/services/completion.rs | Segments / Snippet / Declaration |
| 调试开关 | crates/tabby/src/services/completion.rs | DebugOptions / DebugData |
| 响应结构 | crates/tabby/src/services/completion.rs | CompletionResponse / Choice |
| 补全总调度 | crates/tabby/src/services/completion.rs | CompletionService::generate |
| next-edit 分支 | crates/tabby/src/services/completion.rs | generate_next_edit_suggestion |
| CRLF 归一化 | crates/tabby/src/services/completion.rs | contains_crlf / override_prompt / override_generated_text |
| 服务装配(需模板) | crates/tabby/src/services/completion.rs | create_completion_service_and_chat |
| 片段收集与配额 | crates/tabby/src/services/completion/completion_prompt.rs | PromptBuilder::collect |
| FIM 模板填充 | crates/tabby/src/services/completion/completion_prompt.rs | PromptBuilder::build / build_prompt |
| 片段注入 prefix | crates/tabby/src/services/completion/completion_prompt.rs | build_prefix / rewrite_with_snippets |
| 内联片段优先级 | crates/tabby/src/services/completion/completion_prompt.rs | extract_snippets_from_segments |
| 代码检索片段 | crates/tabby/src/services/completion/completion_prompt.rs | collect_snippets |
| next-edit 提示 | crates/tabby/src/services/completion/next_edit_prompt.rs | NextEditPromptBuilder::build_prompt |
| git_url→source_id | crates/tabby-common/src/axum.rs | AllowedCodeRepository::closest_match |
| 检索查询/参数 | crates/tabby-common/src/api/code.rs | CodeSearchQuery / CodeSearchParams |
| HTTP 入口 | crates/tabby/src/routes/completions.rs | completions |
上一章: 服务骨架:从 CLI 到 axum 路由装配 · 下一章: 仓库上下文:tree-sitter 切片、Tantivy 索引与 RRF 混合检索 · 返回: Tabby 全景与阅读地图