Skip to main content

ProPilot 开发手记(下):团队协作、性能优化与测试评估

· 13 min read
ayanami

从独狼到团队

前期只有一个人在写代码,后端已经积累了约 2500 行代码。小学期正式开始后迎来了 5 个人的团队——前端同学、后端同学和模型侧同学。

第一周的进展:

  • VSCode 前端基本完成
  • 后端路由部分完成
  • 向量数据库和模型嵌入部分完成性能测试与选型
  • 模型数据管道搭建完成
  • Prompt 初次迭代
  • 文档上传与解析部分完成

代码量从前端 4000+ 行(两个前端)加上后端 6000+ 行,迅速突破了一万行。

后端架构概览

项目后端采用了清晰的模块化设计:

├── api/
│ ├── routes/ # 路由层:propilot, users, milvus 等
│ ├── services/ # 业务逻辑层
│ │ ├── completion_service.py # 补全核心
│ │ ├── doc_service.py # 文档处理
│ │ ├── search_engine_service.py # 搜索引擎
│ │ ├── image_service.py # 图片搜索
│ │ ├── web/ # 网络搜索(jina, langchain)
│ │ └── downloader/ # 文档下载器
│ ├── dto/ # 数据传输对象
│ └── models.py # ORM 模型
├── search/
│ └── rag/ # RAG 管道:chunker, hierarchy, prehandler
├── finetune/ # 模型微调相关
├── coherence/ # 连贯性模型
├── prompts/ # Prompt 模板
├── tests/ # 测试:API、Benchmark、ndcg 评估等
├── utils/ # 工具:logger, parser, reranker 等
├── milvus/ # 向量数据库
├── mineru/ # 文档解析(Docker)
└── typesense/ # 搜索引擎

系统级性能瓶颈:LLM 的困境

随着系统跑通,一个严峻的问题浮出水面——全部的时延和精度压力都在 LLM 上

不算 LLM 的其他地方可以做到 100 QPS+(即使有 rerank,reranker 的加速比 LLM 简单多了),但整个系统只要有 1 QPS 就是成功。

两难困境

补全系统有两种 LLM 调用场景:

场景一:给定多篇参考文档 + 用户上下文,LLM 总结生成补全。如果用 32B 以上的模型,基本能很好地理解参考文档;但本地跑不起 32B 的批量推理,只能用 7B 小模型,性能下滑严重。

场景二:如果 reranker 能足够精确(top-k = 1 甚至 0),LLM 的任务就变成简单的句子改写,对小模型会友好得多。但问题是 reranker 只考虑相关性的话,筛选力度不够,top-k 还是得比较大。

Tokenization 的坑

一个更深层的问题是 tokenization:

用户的上下文可能断在奇怪的地方。例如 "另一部分问题,可能是更加严","严峻" 这个词本来是一个 token,被拆开后变成了 "加严",而要从 "加严" 生成 "峻" 是一个相对困难的事情。

如果让 LLM 复读上文再输出,一是浪费 token,二是小模型会抄不准确(32B 以上基本没有这个问题,但 7B 很明显)。

量化也救不了

尝试了 Int4 量化来跑更大的模型(如 Qwen3-30B-A3B),结果发现量化直接把模型搞得不像人类,输出变成了古神的低语。

最终的选型结论:Mistral Small 的 FP8 量化版本在 32B 以下表现最好,远强于 Qwen、GLM、Phi 等同规模模型。

Copilot 的缓存设计参考

在性能优化过程中,深入分析了 VS Code Copilot 的 InlineEdits(Tab 补全)技术实现,发现其缓存机制非常精巧,对我们很有参考价值:

三层缓存架构

  1. 最近显示缓存(RecentlyShownCache):< 50ms,用户快速移动光标时立即恢复之前的建议
  2. 主缓存(NextEditCache):< 300ms,直接命中或通过"重定基"技术调整旧建议
  3. 拒绝列表:避免重复提供已拒绝的建议

重定基(Rebasing)技术

这是最核心的创新——即使用户在建议生成后继续编辑,系统也能智能地调整缓存的建议:

原始文档: "function add"
AI 建议: "function add(a, b) { return a + b; }"
用户继续输入: "function addNumbers"
重定基结果: "function addNumbers(a, b) { return a + b; }"

实现上采用了三方合并算法:将大编辑分解为原子操作,然后对 AI 编辑和用户编辑进行合并,解决位置偏移和冲突。

赛马机制

同时向 LLM 提供者和诊断提供者发起请求,选择最佳结果:

  • 诊断建议优先(基于具体代码问题,准确率高)
  • LLM 建议作为后备
  • 如果 LLM 快速返回空结果,额外等待诊断提供者 1 秒

对我们的启示

这套缓存设计对我们有很强的借鉴意义。尤其是在 RAG 场景下,如果事先把 chunk 都过一遍 vLLM 的 KV Cache,补全时 RAG 得到的 chunk 全部都能命中缓存,这些 chunk 的 KV 计算几乎可以省掉。

不过 GPU 的 KV Cache 容量有限(实测 L40 约 45 万 tokens),全量缓存不太现实。社区建议使用 LMCache 将 KV Cache offload 到 CPU 内存或硬盘,实现冷热分离。

前端开发经验

VSCode Extension 的坑

VSCode 扩展的定制能力其实很差。很多地方有安全性限制,没法自定义。例如 Copilot 的侧边栏实际上是 TreeView + Markdown string,只能嵌入预定义的东西(如图片),不支持自定义样式。

而且 VSCode 里开关 Copilot 太麻烦了,插件冲突是巨大的问题。很多逻辑必须裸写 TypeScript,没法用 React。

Office Add-in 比 WPS JS 好用

前端同学发现 Office 的开发文档远好于 WPS JS,而且 WPS 的文档完全就是抄 Office 的(三四年没更新了)。于是决定改开发 Office 插件。

Claude 写前端

一个共识:Claude 写传统 React 前端如入无人之境。Office 这种自带 React 框架的情况更是 AI 直接法力无边。前端核心代码其实也就六七百行(一些 AI 处理不了的逻辑),剩下的 AI 都能搞定。

测试评估框架

核心问题:如何评估补全系统?

开发补全应用时,一个必须面对的问题是:如何评估整个系统的成效?当然可以让 LLM 评估,但耗费大量 token 不说,整体迭代速度也会慢下来,还有组件太多调参困难的问题。

测试集生成策略

作为传统手艺人,测试集必然是利用现有数据而不是 LLM 生成的。

策略如下:

  1. 选择一篇尚未入库的文档
  2. 在随机位置截断
  3. 将前端的动态选取上下文逻辑迁移到后端
  4. 从截断位置生成上文(往前找直到第一个标点符号)和下文
  5. 得到测试集 (prefix, expect)

组件拆分优化

注意到依赖搜索引擎的前缀补全是快速且模型无关的,所以先对这个进行独立调优。

核心在于:

  1. 搜索引擎超参数:容忍的拼写错误数量、top-k 大小等
  2. 补全选择策略:给定上文 p 和搜索引擎搜到的文档集合 D = {d1, d2, ... dk},如何选取最优补全?

把这个函数 f1 = (p, D) -> completion 作为优化变量,就实现了单独模块的优化迭代机制——你甚至可以用强化学习来优化。

前缀补全的实现

前缀补全不能做成简单的前缀搜索。主要问题在于:

  1. 用户不会知道文档中某个段落的前缀是什么——你会更想搜 "kubernetes" 而不是 "这个文档提到了 kubernetes"
  2. 文档解析会带来误差(多出的空格、错误的符号),没法直接前缀匹配

所以我们的前缀补全是:正常搜索引擎搜索,搜到了再找前缀。

核心算法是一道有趣的题目:

给定两个字符串 s1, s2,求最长公共子串 "是 s1 的结尾且在 s2 中出现",返回子串和 s2 中的结束位置。

async def prefix_completion_strategy(
data: AskCompletionRequest, user_id: str
) -> Optional[AskCompletionResponse]:
"""基于前缀匹配的补全策略"""
typesense_service = SearchEngineService()
typesense_results = await typesense_service.search(
query=data.content.context.current_line, user_id=user_id, filters=data.filters
)
score = 0
for result in typesense_results:
if result.found > 0:
max_prefix_length = 0
longest_completion_string = ""
for hit in result.hits:
if not hit.text_match > score:
continue
document = hit.document
prefix, completion_start_idx = (
longest_common_substring_ending_s1_with_s2_pos(
data.content.context.current_line, document.text
)
)
if prefix and len(prefix) > max_prefix_length:
max_prefix_length = len(prefix)
longest_completion_string = truncate_to_first_punctuation(
document.text[completion_start_idx:]
)
if longest_completion_string:
response = AskCompletionResponse(
response=longest_completion_string,
references=[document],
token_cost=0,
answer_type=AnswerType.PREFIXCOMPLETION,
)
return response

性能意识:先 Benchmark 再优化

组员看到算法后建议用二分 + KMP 优化到 O(n log n),但先跑了个 benchmark:

10000 长度的字符串,31K QPS

对于补全场景来说不可能有明显性能感知,于是放弃了优化。

做工程的和搞算法的想法就是不一样。

后续方向

项目在暑假前完成了一个阶段性版本,后续计划包括:

  1. 性能优化:核心是缓存设计,参考 Copilot 的重定基技术
  2. Web 版前端:抛开 VSCode Extension,写一个支持富文本的 Web 版本,方便调试
  3. 部署管道:搭建 Docker Compose / K8s 一键部署
  4. 小模型选型:找到一个参数量可控且补全效果可用的 LLM

这个项目最终以半成品的形式开源在了 GitHub 上,相当于暑假结课的版本。


从一个人的原型到五个人的协作开发,ProPilot 让我深刻体会到:在 AI 应用开发中,模型只是一小部分,更大的挑战在于系统工程——搜索质量、缓存设计、前后端联动、测试评估的工程化。LLM 的吞吐瓶颈远比想象中严峻,小模型的 tokenization 问题、量化带来的质量退化,都是绕不开的现实问题。

好的 log 结构、接口抽象、组件化测试,这些传统软件工程的智慧,在 AI 时代不仅没有过时,反而因为 AI 辅助编程的加入而变得更加重要。

Loading Comments...