Skip to main content

ProPilot 开发手记(上):从 0 到 1 构建学术写作 Copilot

· 14 min read
ayanami

为什么要写这个东西?

起因有三:

  1. 软件工程原理课要求蹲一个小学期项目,不如写点自己感兴趣的。一直对 Copilot 内部模型和架构很感兴趣,但相关文档难找,开源方案基本都是 prompt 工程而非专用小模型。
  2. 和某实验室交流时聊到"能不能用 AI 帮忙写本子",但直接让 AI 生成学术方案大长篇,一是隐私问题(私密文档不能上传到公网),二是效果太差(学术套话水平不如人类),前者比后者更关键。
  3. 和实验室讨论并做了些前期实践后,发现端到端的 agent 比较扯淡——指望国产模型在专业性这么高的地方不产生幻觉还是太为难了,于是决定做成 copilot 模式

proposal copilot -> propilot,缩写天才吧。

产品设计思路

在学术研究领域,存在大量论文文献、相关方案资料。面向项目方案编写、论文总结等场景,当前 AI 应用存在几个核心痛点:

  • 低幻觉文本归纳生成:AI 需要正确区分成果来源
  • 长文本、特定结构生成:按模板生成 20000+ 字的项目方案
  • 多任务能力:表格生成、UML 图生成等
  • 离线开源 LLM 下的效果优化

由于长篇生成难以保证语言风格和幻觉程度的可用性,产品设计为类似 Copilot 的形式,核心功能包括:

补全模式

用户提供两类文档集合:公域学术论文集合(如子领域参考文献)和私域学术论文集合(如实验室未公开资料),离线处理入库。用户编写本子时,系统提供四种类型的补全:

  • 模板类型总结:如"近年来,人工智能技术的快速发展推动了AI Agent" → 补全为"相关技术的兴起"
  • 本地库查找补全:根据上下文在文档集合中通过语义搜索找到相关句子
  • 更好的 Ctrl+C/V:通过预构建索引从文档集合中原样照抄相关段落
  • 指令补全:类似 Cursor 的 / 命令——/summarize/find/chat/web/arxiv/refine

同时支持 @ 系列命令限制上下文范围:@{filename}@{tag}@{time}@{author}@{domain} 等。

多级补全架构

补全采用多级流水线设计:

L0 缓存
L1 文档过滤,意图识别
L2 本地搜索引擎前缀匹配(typesense/elasticsearch),有前缀匹配就直接生成补全
L3 向量搜索得到相关参考句子
L4 基于语义连贯性和相关程度的重排序
L5 LLM 根据 top-k 个参考生成实时补全

这个设计的关键在于:如果用户文本在文档集里有的话,L2 就能停,能够做到类似 Google 搜索的实时补全,时延 < 100ms

技术栈选型

  • 后端:Litestar(Python)
  • 数据库:Milvus(向量)+ PostgreSQL
  • 消息队列:基于 Redis 或内存 MQ
  • 搜索引擎:Typesense
  • 前端:VSCode Extension / Office Add-in / Obsidian Extension
  • 推理加速:vLLM + text-embedding-inference + TensorRT

为什么搜索引擎才是关键?

做了一个真实的实验,用一个学术本子的片段测试各模型的补全能力:

用户输入:"国内各知名高校均对新兴的技术有所建树,例如清华大学在2023年在国外顶级会议上发布的StarryNet网络仿真器、复旦大学基于开源离散事件模拟器ns-3"

期望补全:"二次开发的面向卫星网络的网络模拟器"

结果令人失望:

  • DeepSeek(无网络搜索):编造了完全不存在的项目名,全是事实性错误
  • DeepSeek(有网络搜索):仍然幻觉,编造了各种框架和政策数据
  • GLM4-32B(无搜索):输出太泛泛
  • QwQ-32B:一堆幻觉
  • GPT-4.1:也不对

只有 GLM4-32B(有网络搜索)给出了接近正确的结果。

实验结论:在这样真实的子领域文本上,即使有向量搜索,LLM 的补全仍然很一般。本地搜索是绝对关键的,后续的 RAG 更多只是将本地搜索的相关句子变成补全而已。

RAG 在这里解决的任务是:给定用户的当前编辑上下文,从数据库中搜索 top-k 相关句子,然后:

  1. 如果有前缀匹配的句子,直接输出补全
  2. 否则需要 reranker 进行重排序,将真正相关的句子捞上来
  3. 对于语义相近但表述不同的内容(如 docker enginecontainerd),需要向量搜索
  4. 对于不同语言风格的表述,需要 LLM 进行改写

Reranker 微调:相关性 vs 连贯性

问题发现

在微调 reranker 的过程中发现,reranker 的预训练以计算句子相关性为主,但在补全任务上,上下文连贯程度同样重要。相关的句子不一定能作为好的补全。

举个例子:"hello, world" 的嵌入和 "hello, world!" 的嵌入自然是靠近的(高相关性),但 "hello, world!" 不太可能成为 "hello, world" 的下文(低连贯性)。

连贯性建模

发现判断两个句子连贯性的任务几乎就是 BERT 的 NSP(Next Sentence Prediction)任务,于是跑了测试:

model_name = "bert-base-multilingual-cased"
tokenizer = BertTokenizer.from_pretrained(model_name)
model = BertForNextSentencePrediction.from_pretrained(model_name)

ROC 曲线显示这个方向是可行的,连贯性分数和相关性分数的结合还有很大的调优空间。

微调经验

微调 BGE Reranker v2 M3 的过程中总结了几条经验:

  1. Hard negative 太 hard 会训崩:直接用搜索引擎的 top-n 和同文档不同位置的句子作为负例太难了
  2. 混合难度:加入和 hard example 同等数量的简单例子(中文 Wikipedia 数据),就能正常调出来
  3. 数据量:3000 个 sample 微调后涨了约 3 个百分点
  4. 句子级别上下文太短:一个句子级别的上下文判断有点太短了,动态上下文还需要进一步做

Embedder 的微调效果不明显(将下一句作为正样本,其他作为负样本),因为 embedder 本身不太适合建模"连贯性"这种需要两个句子才能计算的指标。

Qwen3 Reranker 体验

Qwen3 发布了 0.6B 的 reranker 模型,虽然纯对话输出是一坨,但通过非对话的形式去调用,在某些任务上竟然可以胜任。

一个有趣的实验——用它做地名匹配:

Query: 光体, Best Match: 胡法光体育场, Score: 0.1722
Query: 南体, Best Match: 南区体育馆, Score: 0.2633
Query: 霍体, Best Match: 霍英东体育中心, Score: 0.6565

Reranker/embedder 不属于一般的 chat 产品,它只需要做好排序,不需要输出任何有意义的话。训练时使用对比学习的手法,模型在嵌入空间上将查询和正样本拉近、查询和负样本拉远就行。

Encoder 架构的 reranker 依然坚挺——模型小、QPS 高,例如 BGE 最大的 encoder reranker 单张 H100 在 512 长度输入上能打到 250 QPS。LLM-based 很难做得非常小,而 encoder 架构甚至有 8M 的 CPU-level reranker。

日常踩坑记录

Python 版本地狱

3.13 的支持太臭了,几个核心 feature 都没有 3.13 适配,果断退回 3.12。Conda 和 uv 兼容性也很恶心。

Litestar 的依赖注入陷阱

# 看起来依赖注入了,实际没有
@post("/image/search")
async def image_search(
req: Annotated[ImageSearchRequest, Body(media_type=RequestEncodingType.JSON)],
) -> ImageSearchResponse:

Litestar 的 body 参数注入只认 data 参数名:

# 改成这样就能跑了
@post("/image/search")
async def image_search(
data: Annotated[ImageSearchRequest, Body(media_type=RequestEncodingType.JSON)],
) -> ImageSearchResponse:

subprocess_tee 的 asyncio 坑

subprocess_tee 库直接在代码里用了 asyncio.run(),导致不能 asyncio 套 asyncio,一直炸出 RuntimeWarning: coroutine was never awaited

配环境占一半时间

AI 项目开发的真理:不涉及 CUDA 还好,和模型相关的真配到头秃。xformers 不兼容,降版本编译一次就半天。unsloth 库的上游依赖各更新各的,直接无语。

软件工程实践

接口抽象的力量

以 reranker 为例,不同模型的调用方式千差万别,但都可以归纳为一个接口:

class Reranker:
"""Reranker接口,所有reranker实现都应该继承这个类"""
def __init__(self, name: str):
self.name = name

def compute_score(
self, sentence_pairs: List[List[str]], normalize: bool = True
) -> List[float]:
raise NotImplementedError("每个Reranker子类必须实现compute_score方法")

有了这个接口,评估代码就变得非常简洁:

rerankers = [
RerankerWrapper("BGE-Reranker-Original",
FlagReranker("BAAI/bge-reranker-v2-m3", use_fp16=True)),
RerankerWrapper("BGE-Reranker-Finetuned",
FlagReranker("./finetune/.../checkpoint-1143", use_fp16=True)),
RerankerWrapper("Qwen3-Reranker",
Qwen3Reranker(model_name="Qwen/Qwen3-Reranker-0.6B", instruction="...")),
]
results = evaluator.evaluate(rerankers=rerankers, domain_samples=1000, open_samples=1000)

大项目如何不写成屎山?

三点建议:

一、微服务化:将相对独立的功能解耦出去(文档解析、模型微调、模型评估等),最简单的形式就是用 Docker image 而不是污染环境。

二、抽象接口意识:多用"抽象接口"。AI 相关代码常常只有一个 demo,封装本身就是一种抽象艺术。看看好的传统软件工程代码和设计模式,了解一下高阶函数的思想。

三、写简洁的代码:用现代特性减少工作量——Python 的 @dataclass@cacheEnum@override、Pydantic 等。一个简单的限流器用装饰器就能优雅实现:

def rate_limit(rate_limit: int) -> callable:
"""Decorator: 限制每分钟最大调用次数"""
last_called = [0]
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
current_time = time()
elapsed = current_time - last_called[0]
if elapsed < 60 / rate_limit:
sleep(60 / rate_limit - elapsed)
last_called[0] = time()
return func(*args, **kwargs)
return wrapper
return decorator

关键判断标准:手拼 SQL 是丑的(引入 ORM),字符串传参是丑的(用自定义类型),重复代码是丑的(写 wrapper 或注解),不可复用是丑的(统一接口),深度耦合是丑的(引入消息队列解耦)。

面向 AI 编程

一个格式良好的 log 不仅适合人来 debug,更重要的是适合 AI 快速理解

项目中的 benchmark 用 pytest 的 fixture 和参数化测试快速进行多组实验,保存所有原始文件和 prompt,便于版本控制和追溯。然后只写了几行描述文件架构的注释代码,让 Claude 续写,它一次就写出了 500 行可用的 Streamlit 前端。

你只需要:1. 知道 Claude 写前端很厉害;2. 知道 Streamlit 是很好的低代码前端;3. 注意维护 log 的结构性。一切就自然而然地发生了——这才是面向 AI 编程。

Git 多租户方案

由于共用服务器账号,做了一个 .git-author 文件配合 git hook 的强行多租户方案:

# .git-authors
name1:test1@example.com
name2:test2@example.com

# .git/hooks/prepare-commit-msg
AUTHORS_FILE=".git-authors"
author=$(cat $AUTHORS_FILE | gum choose)
author_name=$(echo $author | cut -d: -f1)
author_email=$(echo $author | cut -d: -f2)
export GIT_AUTHOR_NAME="$author_name"
export GIT_AUTHOR_EMAIL="$author_email"

gum 实现选择界面,每次 commit 自动设置临时的 git user 环境变量。


这是 ProPilot 开发手记的上篇,聚焦于项目的架构设计、RAG 补全流水线的思路、Reranker 微调实验和软件工程实践。下篇将讲述团队开发阶段遇到的 LLM 吞吐瓶颈、系统性能优化和测试评估框架的构建。

Loading Comments...