项目:会翻源码的答疑助手
把 RepoBot 改成一个智能体:先查文档,文档里没有就去翻 httpx 的源码。v2 答不上来的默认值、异常逻辑,v3 都能答对,并注明源码的文件和行号。
- 约 60 分钟
- 难度:进阶
- 实测:2026-09-14 deepseek-flash,multilingual-e5-small
RepoBot v2 有一个绕不过去的限制:它只读文档。"httpx 默认最多跟随几次重定向",文档没写,它就只能说"文档里没有找到"。可答案明明就在 httpx 的源码里,一个 grep 就能找到。
这一课把 RepoBot 改成一个智能体,让它自己决定先查文档还是翻源码。
做完的标准
- 问"httpx 默认最多跟随几次重定向?",回答是 20,并注明
[源码 httpx/_config.py:248]。 - 问文档里有答案的问题,它优先用文档回答,注明
[文档 文件名]。 - 问和 httpx 无关的问题,它直接拒绝,不调用任何工具。
read_source传入../之类跳出源码目录的路径,会被拒绝。eval_agent.py的 8 道题(其中 5 道答案只在源码里)全部答对。
结构
代码在 projects/repobot/v3/:
tools.py 四个工具:search_docs、read_doc、grep_source、read_source
agent.py 智能体循环(05 模块第 2 课的写法)
repobot.py 命令行对话程序
retrieval.py 检索,沿用 v2
llm.py 模型客户端、计费、重试,沿用 v2
eval_agent.py 8 道题的评估
四个工具
v2 的检索被包装成了一个工具,再加上三个新工具:
| 工具 | 做什么 | 什么时候用 |
|---|---|---|
search_docs |
在文档里做混合检索,返回最相关的 5 段 | 问怎么用、是什么时,先用它 |
read_doc |
按行读文档文件 | 检索到的片段不够完整时 |
grep_source |
在 httpx 源码里搜索文本或正则 | 文档里找不到答案时 |
read_source |
按行读源码文件 | grep 找到行号之后 |
说明书按第 3 课的方法写,每个工具都写清楚了什么时候该用。以 grep_source 为例:
@tool("在 httpx 的 Python 源码里搜索一段文本或正则表达式,返回文件路径、行号和那一行,最多 30 条。"
"文档里找不到答案时使用,比如某个参数的默认值、某个异常在什么情况下抛出、某个函数的内部逻辑。",
pattern="要搜索的文本或正则表达式,例如 DEFAULT_MAX_REDIRECTS 或 def raise_for_status")
def grep_source(pattern):
try:
regex = re.compile(pattern)
except re.error:
regex = re.compile(re.escape(pattern))
……
if not hits:
return f"源码里没有找到 {pattern},换个写法再试,比如只搜函数名或常量名"
两个细节:模型传来的正则可能写错,编译失败就退回成普通文本搜索,而不是报错;搜不到时,返回的信息告诉模型下一步可以怎么做。
源码第一次运行时自动克隆:
def ensure_source():
"""第一次运行时把 httpx 的源码克隆下来。"""
if not (SOURCE_DIR / "httpx").is_dir():
print(f"第一次运行,正在从 {SOURCE_REPO} 下载 httpx 源码……", flush=True)
SOURCE_DIR.parent.mkdir(parents=True, exist_ok=True)
subprocess.run(["git", "clone", "--depth", "1", "--quiet", SOURCE_REPO, str(SOURCE_DIR)], check=True)
--depth 1 只下载最新的一个版本,不要全部历史,快得多。
两个读文件的工具共用一个检查函数,保证模型只能读指定目录里的文件:
def inside(base, path):
"""把相对路径转成绝对路径,并确认它没有跑出 base 目录。跑出去了就返回 None。"""
target = (base / path).resolve()
return target if base in target.parents and target.is_file() else None
第 8 课讲过,智能体的每一个工具都是一个可能被利用的入口。RepoBot 只需要读,所以四个工具全是只读的,读的范围也被限制在文档和源码两个目录里。
提示词:先文档,后源码
SYSTEM = """你是 RepoBot,Python HTTP 客户端库 httpx 的答疑助手。你可以查 httpx 的官方文档和源码。
做法:
- 先用 search_docs 查文档。文档里有答案,就根据文档回答。
- 文档里没有答案(比如默认值、内部逻辑、某个异常什么时候抛出),再用 grep_source 和 read_source 查源码。
- 回答里注明依据:文档写成 [文档 文件名],源码写成 [源码 文件路径:行号]。
- 只根据查到的内容回答。查了还是找不到,就如实说没有找到,不要猜。
- 和 httpx 无关的问题,直接礼貌地说明你只负责 httpx,不要调用任何工具。
- 用中文回答,简洁,代码保持原样。"""
为什么先查文档?文档是写给用户看的,讲的是"应该怎么用";源码是实现细节,可能包含不对外承诺的内部行为。能用文档回答的,就不要从源码里挖。另外,检索一次文档通常就够了,翻源码往往要搜好几次、读好几段,更贵。
"用中文回答"这一条是第 4 课的教训:智能体读了大量英文材料后,回答有时会以英文开头。
运行
cd projects/repobot/v3
pip install -r requirements.txt
export HF_ENDPOINT=https://hf-mirror.com
python repobot.py
v2 答不上来的问题,这次问一个更刁的:"只写 httpx.Limits(max_connections=200),max_keepalive_connections 是多少?"文档里只写了"(Defaults 20)",很容易让人以为答案是 20。
你:只写 httpx.Limits(max_connections=200),max_keepalive_connections 是多少?
[1] search_docs({"query": "Limits max_connections max_keepalive_connections default"}) → [1] 文档 advanced/resource-limits.md
[1] grep_source({"pattern": "max_keepalive_connections"}) → httpx/_config.py:167: * **max_keepalive_connections** - Allow the conn
[2] read_source({"path": "httpx/_config.py", "start": 160, "end": 200}) → 160: """
RepoBot:**是 `None`**(即不限制 keep-alive 连接数)。
原因:`httpx.Limits(...)` 构造时 `max_keepalive_connections` 的参数默认值是 `None` [源码 httpx/_config.py:177],你不传它就保持 `None` [源码 httpx/_config.py:181]。
注意别把两个"默认"混淆:
- `Limits` 构造函数的默认值是 `None`
- 而 **客户端在没有传入 `limits` 时**使用的 `DEFAULT_LIMITS = Limits(max_connections=100, max_keepalive_connections=20)` [源码 httpx/_config.py:247],文档里说的 "(Defaults 20)" 指的是这个 [文档 advanced/resource-limits.md]
(后面的代码示例省略)
[3 次模型调用,3 次工具调用,本轮 0.00080 美元,累计 0.00080 美元]
它同时查了文档和源码,第 2 步读了 _config.py 的第 160 到 200 行,然后区分了两个容易混淆的"默认"。我在第 03 模块第 5 课查过这段源码:Limits 的构造函数里 max_keepalive_connections 默认是 None,DEFAULT_LIMITS 在第 247 行。回答完全正确。
评估:8 道题,跑了三次
eval_agent.py 有 8 道题,其中 3 道的答案在文档里,5 道只在源码里。每道题配一个正则表达式,回答里能匹配上就算对。
第一次运行的结果是 8/8 全对。但我把回答逐条看了一遍,发现 Limits 那一题它答的是"是 20,实际生效的默认来自模块级常量",这是错的。它只是在解释里顺带提到了 None,就被我的正则 r"None" 判成了"对"。
这是第 01 模块第 6 课说过的问题:评分脚本也会错。于是我给每道题加了一个"不能出现"的正则,用来挡住"提到了关键词、结论却是错的"这种情况:
QUESTIONS = [
# (问题, 必须出现, 不能出现, 答案在哪)
……
("只写 httpx.Limits(max_connections=200),max_keepalive_connections 是多少?", r"None", r"是\s*\**\s*`?20", "源码"),
]
改好之后,又连着跑了两次:
########## v3 评估第 1 次
答案在文档里的题:3/3 答对
答案在源码里的题:5/5 答对
平均每题 2.6 次模型调用,2.5 次工具调用,共 0.0067 美元
……
########## v3 评估第 2 次
答案在文档里的题:3/3 答对
答案在源码里的题:5/5 答对
平均每题 2.5 次模型调用,2.5 次工具调用,共 0.0063 美元
这两次,Limits 那题都答对了("max_keepalive_connections 会是 None")。三次运行,有一次答错了一题。
这说明两件事。第一,智能体的回答每次都不一样,同一个问题这次对、下次错,跑一次评估的结果不能说明太多,要多跑几次。第二,自动评分的规则写得越严格,越能发现真问题,但也可能误伤正确的回答。这两件事,都是下一个模块要系统解决的。
一个工程上的坑:多线程和本地模型
评估脚本用线程池同时跑 4 个问题。有一次我同时启动了两个评估程序,结果其中一个卡住了十几分钟,一行结果都没有输出,CPU 占用却接近 400%。
原因是本地的嵌入模型。每个线程都在调用它,而 PyTorch 在做计算时自己又会开好几个线程。几个 Python 线程乘以 PyTorch 的线程,再乘以两个进程,线程数远远超过 CPU 核数,大家互相争抢,谁也跑不动。
解决办法是加一把锁,同一时间只让一个线程调用本地模型:
_MODEL_LOCK = threading.Lock() # 同一时间只让一个线程调用本地的嵌入模型和重排模型
……
def vector_search(self, query, k):
with _MODEL_LOCK:
q = self.embedder.encode(["query: " + query], normalize_embeddings=True)[0]
给一个问题算向量只要几毫秒,排队几乎不影响速度。模型调用 API 的等待时间,仍然可以并行。
和 v2 比
| v2 | v3 | |
|---|---|---|
| 流程 | 固定:改写、检索、回答 | 智能体自己决定 |
| 能查的资料 | 文档 | 文档和源码 |
| "默认最多跟随几次重定向" | 文档里没有找到 | 20 [源码 httpx/_config.py:248] |
| 每个问题的调用次数 | 2 次(改写 + 回答) | 平均 2.5 次左右 |
| 每个问题的花费 | 约 0.0006 美元 | 约 0.0008 美元 |
| 可预测性 | 高 | 低,每次步骤可能不同 |
v3 更能干,也更贵、更不可预测。在我们的评估里,它的花费只比 v2 多三成左右,因为大多数问题两三步就解决了。
这个项目要回答的几个问题
- 为什么这样设计? 文档没写的答案在源码里,而什么时候该翻源码、翻哪里,没法事先写死,所以用智能体。工具全部只读,范围限定在两个目录。
- 会在哪里失败? 同一个问题每次的回答可能不同;智能体可能在源码里找到一段相关但不对的代码,据此给出错误的结论;问题涉及好几个文件的调用关系时,可能读不全。
- 怎么评估?
eval_agent.py,每次改动后多跑几次。现在的自动评分还很粗糙,下一个模块会改进。 - 出问题时看什么? 每一步的工具调用都打印出来了,看它搜了什么、读了哪几行。下一个模块会把这些记录成日志。
- 能不能更便宜? 可以先用 v2 的固定流程回答,只在"文档里没有找到"时才启动智能体(第 1 课提到的策略)。
- 真的需要智能体吗? 对"答案在源码里"的问题,需要。对大部分文档问题,其实不需要,这正是上一条优化的依据。
练习
- 问 v3 一个需要跨文件追踪的问题,比如"
httpx.get最终是在哪个函数里真正发出网络请求的?",看看它能走多远,结论对不对。 - 实现第 1 课提到的混合策略:先走 v2 的流程,回答里出现"文档里没有找到"时,再启动 v3 的智能体。比较一下 8 道评估题的总花费。
- 给
eval_agent.py加一个参数,让每道题跑 3 次,统计每道题答对的次数。哪些题是"稳定地对",哪些是"时对时错"?
自测
1. 为什么让 RepoBot 先查文档、后查源码,而不是直接查源码?
文档讲的是对用户承诺的用法,源码里有很多内部实现细节,不一定是稳定的对外行为。而且查文档通常一次检索就够了,翻源码要多次搜索和读取,更贵、更慢。只有文档里没有答案时,才需要去源码里找。
2. 评估脚本显示 8/8 全对,为什么还要逐条看回答?
自动评分可能出错。本课第一次运行时,一道题的结论是错的,只是解释里恰好出现了关键词,就被判成了正确。只有人工看过,才知道评分规则是否可靠,并据此改进规则,比如加上"不能出现"的条件。
3. 多个线程同时调用本地的嵌入模型,为什么会慢到几乎卡住?怎么解决?
PyTorch 每次计算都会自己开多个线程。多个 Python 线程同时调用模型,线程数成倍增加,远超 CPU 核数,互相争抢资源。解决办法是用一把锁,让同一时间只有一个线程调用本地模型。单次计算很快,排队几乎不影响速度。