專案:會翻原始碼的答疑助手
把 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 核數,互相爭搶資源。解決辦法是用一把鎖,讓同一時間只有一個執行緒呼叫本地模型。單次計算很快,排隊幾乎不影響速度。