跳到主要内容

底层引擎:推理抽象、解码停止条件与可插拔模型后端

30 秒导读: 前几章讲了"补全请求怎么进来、上下文怎么检索"。到这一章,我们钻到最底层—— 那段代码文本到底由谁、怎么一个字一个字生成出来。核心答案是:Tabby 用四个很薄的 trait 把"文本生成"抽象掉,上层永远只面对这四个接口;至于背后是本机跑一个 llama.cpp 子进程、还是 打远程 OpenAI 的 API,全被这层抽象藏了起来。

本章面向读过 02-code-completion(补全主流程)和 03-retrieval-and-indexing(检索)的读者。这里回答一个问题: 当上层拼好一段 prompt、调用 generate 之后,发生了什么。


1. 这是什么(先建立直觉)

1.1 一句话定义

推理后端 = 一层"文本进、文本出"的可插拔接口。 上层(补全服务、Answer Engine)只知道 "我给你一段 prompt,你流式吐给我 token";它不关心这些 token 是本地 GPU 算的还是远程 API 返的。

1.2 它解决什么问题

Tabby 想同时支持两类部署,而上层代码只想写一遍:

部署形态谁在真正生成 token典型场景
本地模型Tabby 自己拉起的 llama-server 子进程,加载 GGUF 权重内网、离线、隐私优先
远程模型OpenAI / Mistral / Azure / vLLM / Ollama 等 HTTP API想用更强的云端大模型

如果没有抽象层,补全逻辑里就会散落 if 本地 { … } else if OpenAI { … } 的分支。Tabby 的做法是: 把两者都实现成同一组 trait,上层拿到的永远是 Arc<dyn CompletionStream> 这样的 trait 对象。

1.3 一句话类比

把这四个 trait 想成电源插座标准:墙上的插座形状固定(trait),背后接的是水电站还是太阳能板 (本地/远程)无所谓——只要插头符合标准,电器(上层)就能用。


2. 顶层全景(它大概怎么转)

整个"生成"栈从上到下分三层:抽象层 → 适配层 → 真实后端

┌─────────────────────────────────────────────┐
上层调用 │ 补全服务 / Answer Engine(见第 2、5 章) │
(只认 trait) └───────────────┬─────────────────────────────┘
│ 只面对 4 个 trait 对象
┌────────────────────────────┼────────────────────────────┐
▼ ▼ ▼
抽象层 crate ┌─────────────────────┐ (trait 定义)
tabby-inference → │ CompletionStream │ 文本补全,流式
│ ChatCompletionStream│ 多轮对话(OpenAI 协议)
│ Embedding │ 文本 → 向量
│ CodeGeneration │ 在补全流上加"停止条件"
└─────────┬───────────┘
│ 谁来实现这些 trait?
┌────────────────────┴────────────────────┐
▼ ▼
本地后端 远程后端
llama-cpp-server crate http-api-bindings crate
├ 拉起 llama-server 子进程 ├ llama.cpp / openai
├ 守护 + 崩溃自动重启 ├ mistral / azure / vllm
└ 再用 http 客户端连回自己的子进程 ──────┐ └ ollama …
│ │
▼ ▼
两者最终都走 HTTP,复用同一套 http-api-bindings 适配器

这张图怎么读: 从上往下是"越来越具体"。关键洞察在最底下——本地后端并不直接调 C++ 库, 而是把 llama.cpp 当成一个本机 HTTP 服务拉起来,然后用和"远程 API"完全一样的 HTTP 适配器连回去。 于是"本地"和"远程"在代码路径上高度统一,差别只在"这个 HTTP 端点是我自己起的、还是别人的"。

各部件一句话职责:

部件干什么在哪个 crate
四大 trait定义"生成"的抽象接口crates/tabby-inference
CodeGeneration在补全流上叠加"按语言停止"crates/tabby-inference
LlamaCppSupervisorfork/守护/重启 llama-server 进程crates/llama-cpp-server
HTTP 适配器把各家 API 协议翻译成 traitcrates/http-api-bindings
ModelRegistry / 下载器解析 model_id、下载 GGUF 权重crates/tabby-commoncrates/tabby-download

3. 抽象层:四个 trait 定义了"什么叫生成"

本节讲最核心的那层——tabby-inference crate。它几乎不含业务逻辑,只定义接口

3.1 四个 trait 各管一件事

入口在 crates/tabby-inference/src/lib.rs:1-11,一次性把四类能力导出:

trait输入 → 输出定义位置
CompletionStreamprompt → 流式 Stringcompletion.rs:17-33
ChatCompletionStreamOpenAI 对话请求 → 对话响应/流chat.rs:12-23
Embeddingprompt → Vec<f32> 向量embedding.rs:3-6
CodeGenerationprompt → 一整段补全(带停止)code.rs:36-55

前三个是纯 trait(接口),第四个 CodeGeneration具体结构体——它不是"另一种后端", 而是包在 CompletionStream 外面的一层加工(见 §4)。

CompletionStream 有多薄(completion.rs:17-33):

#[async_trait]
pub trait CompletionStream: Sync + Send {
/// 流式生成:一段 prompt 进,一串 String chunk 出
async fn generate(&self, prompt: &str, options: CompletionOptions)
-> BoxStream<'life0, String>;

/// 非流式:默认实现就是把上面的流收集成一个完整 String
async fn generate_sync(&self, prompt: &str, options: CompletionOptions) -> String {}
}

妙在 generate_sync默认实现——任何后端只要实现流式 generate,就白得一个"一次性拿全部" 的版本(内部 while let Some(chunk) = stream.next().await 拼接,completion.rs:25-32)。§4 会看到 next-edit 模式正是靠它。

3.2 生成参数:CompletionOptions

generate 的第二个参数是 CompletionOptions(completion.rs:5-15),用 derive_builder 生成 builder。字段很少但关键:

字段含义
max_decoding_tokens最多生成多少 token
sampling_temperature采样温度(补全默认 0.1,偏确定性)
seed随机种子
presence_penalty存在惩罚,默认 0.0

3.3 两个小而关键的工具函数

lib.rs 里还藏着两个到处被用的工具函数。

clip_prompt —— UTF-8 安全的"从头截断"(lib.rs:26-37)。prompt 太长要截掉前面一段, 但字节切割可能切在一个多字节字符(如中文、emoji)中间,导致 panic。它的做法是:算出起点后, 只要该位置不是字符边界就往后挪一格,直到落在合法边界:

let mut start = prompt.len() - max_length;
while !prompt.is_char_boundary(start) { // 不是字符边界就跳过
start += 1;
}
&prompt[start..] // 保留后半段

它的单测(lib.rs:44-68)专门覆盖了 2 字节(é)、3 字节()、4 字节(😀)的边界—— 这类"看起来小、错了就崩"的细节,正是本项目工程化的体现。

default_seed —— 用毫秒时间戳当种子(lib.rs:13-18)。没显式给 seed 时,拿当前 UNIX_EPOCH 毫秒数,失败则退回 0(unwrap_or_default)。


4. 代码生成与流式解码:CodeGeneration 怎么"知道该停了"

本节讲第四个 trait 背后的具体逻辑——模型不会自己聪明地停在合适位置,得由 Tabby 来判断 "这段补全到此为止"。 这是补全质量的关键。

4.1 它要解决的小问题

代码补全里,模型很容易"话痨":你只想补完当前函数,它却继续生成下一个函数、甚至 <|file_sep|> 这类特殊 token。所以需要停止条件:一旦生成的文本里出现某些"边界标志",就立刻截断。

CodeGeneration 就是干这个的。它持有一个底层 CompletionStream 和一个 StopConditionFactory (code.rs:36-39):

pub struct CodeGeneration {
imp: Arc<dyn CompletionStream>, // 真正生成的后端
stop_condition_factory: StopConditionFactory, // 按语言维护停止词
}

构造时(code.rs:42-54),它从模型配置里读出用户自定义的 additional_stop_words,交给 StopConditionFactory::with_stop_words。本地和远程配置都支持这个字段。

4.2 两种模式:标准流式 vs next-edit 一次性

CodeGeneration::generate(code.rs:58-101)里有个岔路,取决于 options.mode:

generate(prompt, options)

├─ 先 clip_prompt(prompt, max_input_length) // 截断过长输入

├─ mode == "next_edit_suggestion" ?
│ │
│ ├─ 是 → imp.generate_sync(...) // 一次性拿全部,不做停止判断
│ │
│ └─ 否(standard)→ imp.generate(...) 流式
│ └ 每来一块就喂给 stop_condition,命中即截断
  • standard 模式:走流式 + 停止条件(下面 §4.3 详解)。
  • next_edit_suggestion 模式:直接 generate_sync(code.rs:73-76),因为"下一处编辑建议" 需要完整结果、不适合边流边砍。这正好复用了 §3.1 那个免费的默认实现。

4.3 停止条件的巧思:反转字符串 + trie 前缀匹配

这是本章最值得学的一段。问题是:每次流式只来一小块新文本,如何高效判断"到目前为止生成的 全文,末尾是否命中了某个停止词"?

朴素做法:每次把全文和所有停止词做后缀比对——慢。

Tabby 的做法(crates/tabby-inference/src/decoding.rs):把停止词反转后建成一棵 trie(前缀树),再把"已生成文本"也反转;这样"某停止词是全文的后缀"就等价于"反转停止词是 反转全文的前缀"——而前缀匹配正是 trie 的拿手好戏。

建 trie 时逐词 reverse 再 push(decoding.rs:65-71):

fn create_stop_trie(stop_words: Vec<String>) -> Trie<u8> {
let mut builder = TrieBuilder::new();
for word in stop_words {
builder.push(reverse(word)) // 停止词反转后入 trie
}
builder.build()
}

判断时,新文本反转后拼到已反转全文的前面,再做一次 common_prefix_search (decoding.rs:88-102):

pub fn should_stop(&mut self, new_text: &str) -> (bool, usize) {
self.num_decoded += 1;
if !new_text.is_empty() {
self.reversed_text = reverse(new_text) + &self.reversed_text; // 新块反转后前插
if let Some(re) = &self.stop_trie {
let matches = re.common_prefix_search(&self.reversed_text);
let matched_length = matches.into_iter().map(|x| x.len()).max();
if let Some(matched_length) = matched_length {
return (true, matched_length); // 命中!返回匹配长度
}
}
}
(false, 0)
}

回到 code.rs:86-95:上层拿到 (should_stop, stop_length) 后,用 stop_length已经吐出的 停止词那部分从结果里 truncate,只保留干净的补全:

let (should_stop, stop_length) = stop_condition.should_stop(&new_text);
text += &new_text;
if should_stop {
// stop_length 可能 ≥ text.len(),用 checked_sub 兜底防下溢
let new_text_length = text.len().checked_sub(stop_length).unwrap_or_default();
text.truncate(new_text_length);
break;
}

按语言维护 + 缓存:停止词是"语言级"的。StopConditionFactory::get_trie (decoding.rs:44-62)先取该语言内建停止词(language.get_stop_words())、再追加模型配置里的 自定义停止词,然后以语言名为 key 缓存 trie(DashMap,decoding.rs:52-58),避免每次补全都 重建。测试里能看到实战停止词,如 Qwen2.5-Coder 的 <|file_sep|>(decoding.rs:123)。

关键细节: should_stop 里的 num_decoded += 1 每次自增,但当前版本并没有拿它做"最大长度 硬停"——长度上限由 max_decoding_tokens 在后端侧控制。测试 test_stop_condition_max_length (decoding.rs:131-143)只验证"无停止词时不会误停"。

4.4 对话与 OpenAI 协议兼容:ExtendedOpenAIConfig

补全走 CompletionStream,而多轮对话ChatCompletionStream(chat.rs:12-23),它直接采用 OpenAI 的请求/响应类型(CreateChatCompletionRequest 等)。

难点在于"各家 OpenAI 兼容 API 又各有脾气"。Tabby 用 ExtendedOpenAIConfig(chat.rs:25-37) 在发请求前做协议校正(process_request,chat.rs:44-73):

场景校正动作
请求没带 model 名填入配置里的 model_name(chat.rs:48-49)
model 名不在 supported_models警告并回退到默认 model(chat.rs:50-58)
mistral/chat清掉 presence_penalty/user/stream_options(chat.rs:61-65)
openai/chat 且是 o1/o3-mini清掉 presence_penalty/frequency_penalty(chat.rs:80-83)

这些都是"某家 API 不认某个字段就报错"踩出来的坑,集中在这一层抹平。


5. 本地后端:把 llama.cpp 当子进程来养

本节讲 crates/llama-cpp-server——本地部署的核心。它的定位一句话:Tabby 不在进程内链接 llama.cpp,而是把官方 llama-server 可执行文件当成一个受监督的子进程拉起来。

5.1 为什么这么设计

进程隔离带来两个好处:llama.cpp 崩了(常见于显存不足)不会拖垮 Tabby 主进程;而且本地和远程 共用同一套 HTTP 客户端——本地无非是"连到自己 fork 出来的那个端口"。

5.2 LlamaCppSupervisor:fork、守护、自动重启

LlamaCppSupervisor::new(crates/llama-cpp-server/src/supervisor.rs:25-171)在一个 tokio::spawn无限 loop 里管理子进程:

LlamaCppSupervisor::new(...)

├─ find_binary_name() // 在 exe 同目录找 "llama-server*",找不到再 which
├─ get_available_port() // 30888..40000 里挑一个空闲端口

└─ tokio::spawn(async loop { // 守护循环
spawn llama-server 子进程,拼参数:
-m <model_path> --cont-batching --port <port>
-np <parallelism> --ctx-size <context_size>
[num_gpu_layers>0 → -ngl N] [embedding → --embedding …]
[chat_template → --chat-template] [fast_attention → -fa]

├─ 边跑边读 stderr,保留最近 100 行错误(过滤 GET /health 噪音)
├─ 进程退出 → 拿 status_code
└─ status_code != 0(崩溃):
├─ 打印最近错误 + analyze_error_message 给"人话建议"
├─ 若 retry_count≥5 且启动不到 60s → std::process::exit(1) 放弃
└─ 否则 sleep 1s,retry_count++,重进 loop 重启
})

几处值得留意的细节:

  • 端口分配:get_available_port(supervisor.rs:247-251)在 30888..40000 里逐个尝试 TcpListener::bind("127.0.0.1", port),能绑上就算空闲。
  • 崩溃诊断成"人话":analyze_error_message(supervisor.rs:200-222)匹配 stderr—— 看到 cudaMalloc 就提示"换更小的模型/降低显存占用";在 x86 上看到 Illegal instruction 且 CPU 不支持 AVX2,就提示去下载兼容二进制。这是把底层 C++ 报错翻译给终端用户看。
  • 快速失败 vs 无限重试:只有"启动 60 秒内连崩 5 次"才 exit(1)(supervisor.rs:155-161); 过了启动期的偶发崩溃则一直 sleep+重启,追求长期可用。
  • kill_on_drop(true)(supervisor.rs:65)+ Drophandle.abort() (supervisor.rs:257-261):supervisor 一销毁,子进程也跟着被杀,不留僵尸。

5.3 start:健康检查轮询

new 只是拉起进程,不等它就绪。真正"等到能用"靠 start(supervisor.rs:177-197):循环 GET {api_endpoint}/health,直到返回成功才 return。注意这里的 HTTP 客户端刻意 .no_proxy()(supervisor.rs:179)——连本机端口不该走代理。

5.4 把子进程包成 trait 对象

进程起来后,lib.rs 把它包成前面那几个 trait 的实现。三个包装器结构几乎对称:

包装器kind 字符串实现的 trait位置
EmbeddingServerllama.cpp/embeddingEmbeddinglib.rs:21-66
CompletionServerllama.cpp/completionCompletionStreamlib.rs:68-117
ChatCompletionServeropenai/chat(model 名 local)ChatCompletionStreamlib.rs:119-179

CompletionServer::new_with_supervisor(lib.rs:96-105)为例,它用 supervisor 的端口拼出 HttpModelConfig,再交给 http_api_bindings::create 造出真正的客户端:

let config = HttpModelConfigBuilder::default()
.api_endpoint(Some(api_endpoint(server.port()))) // 连回自己的子进程
.rate_limit(build_rate_limit_config())
.kind("llama.cpp/completion".to_string())
.build()...;
let completion = http_api_bindings::create(&config).await; // 复用远程适配器!

这就是 §2 那句"本地最终也走 HTTP、复用同一套适配器"的落点。api_endpoint (lib.rs:17-19)就是 http://127.0.0.1:{port};build_rate_limit_config(lib.rs:356-361)给本地 限了个宽松的 6000 请求/分钟(本质是防雪崩,不是真限速)。

一个巧妙复用:create_completion_and_chat(lib.rs:220-265)在"补全模型 == 对话模型"时, 让补全和对话共用同一个 LlamaCppSupervisor(Arc 克隆,lib.rs:251-252),只起一个子进程、 省一份显存。


6. 远程后端:HTTP API 绑定与多家适配

本节讲 crates/http-api-bindings——所有"通过 HTTP 说话"的后端都在这。上一节看到,连本地 llama.cpp 也从这里出。

6.1 一个 kind 字符串,分发到对应适配器

三个入口函数按 model.kind 字符串 match 到具体引擎:

入口位置支持的 kind(节选)
create(补全)completion/mod.rs:15-57llama.cpp/completionollama/completionmistral/completionopenai/completionvllm/completion
create_chat(对话)chat/mod.rs:10-54azure/chatopenai/chatmistral/chat
create_embedding(向量)embedding/mod.rs:18-79llama.cpp/embeddingopenai/embeddingvoyage/embeddingazure/embeddingmistral/embeddingollama/embedding

每个引擎都返回 Box<dyn CompletionStream>(或对应 trait),外面再统一裹一层限速(见 §6.4)。 碰到不认识的 kind 一律 panic(如 completion/mod.rs:48-50)——配置错就早崩,不静默。

6.2 补全适配器怎么翻译协议

三家补全适配器结构相似(都用 reqwest_eventsource 收 SSE 流),差别在请求体字段名FIM(填空)处理:

适配器HTTP 路径请求体特点FIM 支持
LlamaCppEngine/completionn_predict/penalty_last_n 等 llama.cpp 原生字段不拆(prompt 整体传)
OpenAICompletionEngine/completions标准 max_tokens/suffixsupport_fim 决定拆不拆
MistralFIMEngine/v1/fim/completionsrandom_seed/prompt+suffix总是拆

FIM(Fill-In-the-Middle,中间填空) 是代码补全的关键:光标前是 prefix、光标后是 suffix, 模型要在中间补。上层用一个特殊 token <|FIM|> 把两段拼在一起传下来 (FIM_TEMPLATE = "{prefix}<|FIM|>{suffix}",completion/mod.rs:59-60),适配器再用 split_fim_prompt(completion/mod.rs:76-79)拆回 prefix/suffix。以 OpenAI 适配器为例 (completion/openai.rs:72-86):

let (prompt, suffix) = if self.support_fim {
split_fim_prompt(prompt) // 支持 FIM:拆成 prompt + suffix
} else {
(prompt, None) // 不支持:整段当 prompt,suffix 留空
};

所有适配器的流式循环长得一样:EventSource 逐条读 SSE,解析出 chunk 的文本 yield 出去, 遇到 stop/finish_reason 就 break(如 completion/llama.rs:68-88)。

6.3 一个安全细节:localhost 不走代理

create_reqwest_client(lib.rs:10-22)判断 endpoint 是不是 http://localhosthttp://127.0.0.1,是就 .no_proxy():

let is_localhost = api_endpoint.starts_with("http://localhost")
|| api_endpoint.starts_with("http://127.0.0.1");
let builder = if is_localhost { builder.no_proxy() } else { builder };

因为本地 llama.cpp 子进程(§5)也是通过这个客户端连的——若企业环境配了 HTTP_PROXY,连本机 端口却走代理会直接失败。这一小段把"本地/远程复用同一适配器"的最后一个坑填上了。

6.4 限速:leaky bucket 包一层

rate_limit.rs 用漏桶(leaky_bucket::RateLimiter)给每个 trait 都做了个透明装饰器: RateLimitedCompletionRateLimitedChatStreamRateLimitedEmbedding。它们持有内层实现 + 一个限速器,在真正调用前先 acquire(1)(rate_limit.rs:37-4362-7188-104)。

速率换算很直白(rate_limit.rs:14-21):rpm/60 向上取整成每秒请求数,initial/refill 都用它。 这样"每分钟 N 次"的配置就变成每秒稳定放行的漏桶。

6.5 embedding 的两个真实坑

Embedding trait 只有一个方法(embedding.rs:3-6),但 LlamaCppEngine 的实现里塞满了实战经验 (embedding/llama.rs:60-137):

  • 最少 4 token:llama.cpp 要求至少 4 token 才能算 embedding,所以先 tokenize,不足就补 \n(embedding/llama.rs:66-73)。
  • 端点随版本变:llama.cpp 在 b4356 后把 /embedding 改成 /embeddings、响应也包成数组; 用 before_b4356 布尔切换两套端点和解析(embedding/llama.rs:16-2475-136)。
  • 偶发连接重置就重试:空闲后首批并发请求容易 Connection reset by peer,用指数退避重试 3 次 (embedding/llama.rs:88-114)。

7. 模型从哪来:注册表与下载

本节讲"本地模型的权重文件怎么被找到、被下载"——tabby-common 的 registry 与 tabby-download

7.1 model_id 的解析

用户配置里写的是 TabbyML/StarCoder-1B 这样的 id。parse_model_id (crates/tabby-common/src/registry.rs:183-192)把它拆成 (registry, name):一段就默认 registry 为 TabbyML,两段就按 org/name 拆,超过两段直接 panic。

7.2 ModelRegistry:远程优先、本地兜底

ModelRegistry::new(registry.rs:97-106)先从 GitHub 拉 models.json (load_remote_registry,registry.rs:42-72,URL 形如 raw.githubusercontent.com/{registry}/registry-tabby/main/models.json),拉成功就顺手写到本地 缓存;拉失败(离线)则回退读本地缓存(load_local_registry)。这保证联网首次拉取、之后可离线。

拿到 registry 后,get_model_info(registry.rs:172-180)按 name 找 ModelInfo(含 prompt_template/chat_template/urls/sha256/partition_urls,registry.rs:9-30)。

7.3 分片模型与"入口文件"

大模型被切成多片 GGUF。命名规则是 model-00001-of-00005.gguf 这样;get_model_entry_path (registry.rs:120-133)在模型目录里找model-00001-of- 开头的那个文件当加载入口 (前缀常量 GGML_MODEL_PARTITIONED_PREFIX,registry.rs:87)。llama-cpp-server 侧的 resolve_model_path(lib.rs:290-309)也用同一逻辑,并兼容"直接给本地路径"的情况。

7.4 下载流程

download_model(crates/tabby-download/src/lib.rs:196-214)是入口:解析 id → 建 registry → 可选校验模型 kind(补全模型必须有 prompt_template、对话模型必须有 chat_template, validate_model_kind,lib.rs:216-240)→ 真正下载。

download_model_impl(lib.rs:70-161)的要点:

  • URL 选择:filter_download_address(lib.rs:21-62)按 TABBY_DOWNLOAD_HOST(默认 huggingface.co)挑地址,支持镜像 host 替换——方便被墙环境换 hf-mirror。
  • 幂等:已存在且 prefer_local_file 就直接返回;否则校验 sha256,不匹配才重下 (lib.rs:94-119)。
  • 断点友好:每片先下成 .tmprename(lib.rs:170-187),避免半截文件被当成完整模型; 下载本身用 tokio_retry 指数退避重试(lib.rs:147-158)。

8. 巧妙之处(可带走的技术)

  • 反转字符串 + trie 做流式停止判断(decoding.rs:88-102):把"后缀匹配"转成"前缀匹配", 再靠 trie 的 common_prefix_search 做到 O(匹配长度)。处理"边流边判断停止"的通用招式。
  • 本地后端复用远程适配器(llama-cpp-server/src/lib.rs:96-105):把本机 llama.cpp 拉成 HTTP 服务,于是本地/远程走同一条 HTTP 代码路径,if 本地/远程 的分支被彻底消灭。
  • 崩溃诊断翻译成人话(supervisor.rs:200-222):把 cudaMallocIllegal instruction 这类 底层报错映射成"换小模型 / 你的 CPU 不支持 AVX2"的可操作建议。
  • UTF-8 安全截断(lib.rs:26-37):is_char_boundary 循环兜底,避免多字节字符被切崩。
  • generate_sync 默认实现(completion.rs:25-32):后端只写流式版,免费获得非流式版, next-edit 模式直接复用。

9. 边界与局限(诚实说)

  • 本地推理不在进程内:Tabby 不直接链接 llama.cpp,依赖同目录下有 llama-server 二进制; 找不到就 panic(supervisor.rs:35-37)。跨平台/指令集兼容性问题会以子进程崩溃的形式出现。
  • 配置错就 panic,不容错:不认识的 kind、缺 api_endpoint/model_name、模型 kind 不匹配 等,一律直接 panic(如 completion/mod.rs:48-50chat/mod.rs:47)。设计取向是"早崩早发现"。
  • 停止条件不含 token 级最大长度硬停:num_decoded 自增但未用于强制停止(decoding.rs:89), 长度上限交给后端的 max_decoding_tokens
  • 各家 API 兼容靠硬编码分支:ExtendedOpenAIConfig::process_request(chat.rs:60-73)里 mistral / o1 / o3-mini 的特判是写死的,新出的模型脾气得手动加。

10. 横向对比与上下游

  • 谁调用 CodeGeneration::generate:补全服务 (crates/tabby/src/services/completion.rs)构建 CodeGenerationOptions 并调用——完整的补全 主流程见 02-code-completion
  • Embedding 被谁用:检索/索引侧把文本转成向量做混合检索,见 03-retrieval-and-indexing
  • ChatCompletionStream 被谁用:企业面的 Answer Engine,见 05-enterprise-webserver
  • 这些 trait 对象怎么被装配进服务:见 01-serving-and-request-flowcrates/tabby/src/services/model/mod.rs(load_code_generation_and_chat)。

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

主题文件路径关键符号
四大 trait 导出 + 工具函数crates/tabby-inference/src/lib.rsclip_promptdefault_seed
补全流接口 + 参数crates/tabby-inference/src/completion.rsCompletionStreamCompletionOptionsgenerate_sync
对话流接口 + 协议校正crates/tabby-inference/src/chat.rsChatCompletionStreamExtendedOpenAIConfigprocess_request
向量接口crates/tabby-inference/src/embedding.rsEmbedding
代码生成 + 两种模式crates/tabby-inference/src/code.rsCodeGenerationCodeGenerationOptionsgenerate
停止条件(反转+trie)crates/tabby-inference/src/decoding.rsStopConditionFactorycreate_stop_trieshould_stop
llama.cpp 子进程监督crates/llama-cpp-server/src/supervisor.rsLlamaCppSupervisoranalyze_error_messageget_available_port
本地后端包装成 traitcrates/llama-cpp-server/src/lib.rsCompletionServerChatCompletionServerEmbeddingServerapi_endpoint
HTTP 客户端 + localhost 免代理crates/http-api-bindings/src/lib.rscreate_reqwest_clientAZURE_API_VERSION
补全适配器分发 + FIMcrates/http-api-bindings/src/completion/mod.rscreatebuild_completion_promptsplit_fim_prompt
llama.cpp / openai / mistral 补全crates/http-api-bindings/src/completion/{llama,openai,mistral}.rsLlamaCppEngineOpenAICompletionEngineMistralFIMEngine
对话适配器分发crates/http-api-bindings/src/chat/mod.rscreate
向量适配器分发crates/http-api-bindings/src/embedding/mod.rscreate
llama.cpp 向量实现crates/http-api-bindings/src/embedding/llama.rsLlamaCppEngine::embedtokenize
限速装饰器crates/http-api-bindings/src/rate_limit.rsnew_completionnew_chatnew_embedding
模型注册表crates/tabby-common/src/registry.rsparse_model_idModelRegistryget_model_entry_path
模型下载crates/tabby-download/src/lib.rsdownload_modeldownload_model_implfilter_download_address