模組 05 · 第 9 課

專案:會翻原始碼的答疑助手

把 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 預設是 NoneDEFAULT_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 課提到的策略)。
  • 真的需要智慧體嗎? 對"答案在原始碼裡"的問題,需要。對大部分文件問題,其實不需要,這正是上一條最佳化的依據。

練習

  1. 問 v3 一個需要跨檔案追蹤的問題,比如"httpx.get 最終是在哪個函數里真正發出網路請求的?",看看它能走多遠,結論對不對。
  2. 實現第 1 課提到的混合策略:先走 v2 的流程,回答裡出現"文件裡沒有找到"時,再啟動 v3 的智慧體。比較一下 8 道評估題的總花費。
  3. eval_agent.py 加一個參數,讓每道題跑 3 次,統計每道題答對的次數。哪些題是"穩定地對",哪些是"時對時錯"?

自測

1. 為什麼讓 RepoBot 先查文件、後查原始碼,而不是直接查原始碼?

文件講的是對使用者承諾的用法,原始碼裡有很多內部實現細節,不一定是穩定的對外行為。而且查文件通常一次檢索就夠了,翻原始碼要多次搜尋和讀取,更貴、更慢。只有文件裡沒有答案時,才需要去原始碼裡找。

2. 評估指令碼顯示 8/8 全對,為什麼還要逐條看回答?

自動評分可能出錯。本課第一次執行時,一道題的結論是錯的,只是解釋裡恰好出現了關鍵詞,就被判成了正確。只有人工看過,才知道評分規則是否可靠,並據此改進規則,比如加上"不能出現"的條件。

3. 多個執行緒同時呼叫本地的嵌入模型,為什麼會慢到幾乎卡住?怎麼解決?

PyTorch 每次計算都會自己開多個執行緒。多個 Python 執行緒同時呼叫模型,執行緒數成倍增加,遠超 CPU 核數,互相爭搶資源。解決辦法是用一把鎖,讓同一時間只有一個執行緒呼叫本地模型。單次計算很快,排隊幾乎不影響速度。