モジュール 05 · 第 9 回

プロジェクト:ソースコードを調べる Q&A アシスタント

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,不要调用任何工具。
- 用中文回答,简洁,代码保持原样。"""

なぜ先にドキュメントを調べるのでしょうか。ドキュメントはユーザー向けに書かれていて、「どう使うべきか」を述べています。ソースコードは実装の細部で、外部に約束していない内部のふるまいを含むかもしれません。ドキュメントで答えられるなら、ソースコードから掘り出すべきではありません。さらに、ドキュメントはふつう 1 回検索すれば足りますが、ソースコードを調べるには何度も検索し、何か所も読む必要があることが多く、より高くつきます。

「中国語で答える」というルールは第 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 問を 3 回実行

eval_agent.py には 8 問あり、そのうち 3 問は答えがドキュメントに、5 問はソースコードにしかありません。各問題には正規表現を一つ付けてあり、回答がそれにマッチすれば正解とします。

1 回目の実行結果は 8/8 で全問正解でした。しかし回答を一つずつ読んだところ、Limits の問題では「20 で、実際に効く既定値はモジュールレベルの定数から来る」と答えていて、これは間違いです。説明の中でついでに None に触れていたので、私の正規表現 r"None" に「正解」と判定されてしまったのです。

これはモジュール 01 第 6 課で述べた問題です。採点スクリプトも間違える。そこで各問題に「現れてはいけない」正規表現を加え、「キーワードには触れているのに、結論が間違っている」ケースを止めるようにしました。

QUESTIONS = [
    # (问题, 必须出现, 不能出现, 答案在哪)
    ……
    ("只写 httpx.Limits(max_connections=200),max_keepalive_connections 是多少?", r"None", r"是\s*\**\s*`?20", "源码"),
]

直した後、続けて 2 回実行しました。

########## v3 评估第 1 次
答案在文档里的题:3/3 答对
答案在源码里的题:5/5 答对
平均每题 2.6 次模型调用,2.5 次工具调用,共 0.0067 美元
……
########## v3 评估第 2 次
答案在文档里的题:3/3 答对
答案在源码里的题:5/5 答对
平均每题 2.5 次模型调用,2.5 次工具调用,共 0.0063 美元

この 2 回では、Limits の問題はどちらも正解でした(「max_keepalive_connectionsNone になる」)。3 回の実行のうち、1 回で 1 問を間違えたことになります。

これは二つのことを示しています。一つ目に、エージェントの回答は毎回違い、同じ質問でも今回は正解、次は不正解ということがあるので、評価を 1 回実行しただけでは多くを語れず、何回か実行する必要があります。二つ目に、自動採点のルールを厳しく書くほど本当の問題を見つけられますが、正しい回答を巻き添えにすることもあります。この二つは、どちらも次のモジュールで体系的に解決する課題です。

エンジニアリング上の落とし穴:マルチスレッドとローカルモデル

評価スクリプトはスレッドプールで 4 問を同時に実行します。あるとき評価プログラムを二つ同時に起動したら、一方が十数分固まり、結果を 1 行も出力しないのに、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]
1 問あたりの呼び出し回数 2 回(書き換え + 回答) 平均 2.5 回程度
1 問あたりの費用 約 0.0006 ドル 約 0.0008 ドル
予測しやすさ 高い 低い。毎回ステップが違うかもしれない

v3 はより有能ですが、より高く、より予測しにくくなっています。私たちの評価では、費用は v2 より 3 割ほど多いだけでした。ほとんどの質問が 2~3 ステップで片づいたからです。

このプロジェクトで答えるべき問い

  • なぜこの設計にしたのか? ドキュメントに書かれていない答えはソースコードにあり、いつソースコードを調べるべきか、どこを調べるかは前もって固定で書けないので、エージェントを使いました。ツールはすべて読み取り専用で、範囲は二つのディレクトリに限っています。
  • どこで失敗するのか? 同じ質問でも毎回回答が違うかもしれない。エージェントがソースコードの中で関係はあるが正しくないコードを見つけ、それをもとに誤った結論を出すかもしれない。質問が複数のファイルにまたがる呼び出し関係に関わるとき、すべてを読みきれないかもしれない。
  • どうやって評価するのか? eval_agent.py を、変更のたびに何回か実行します。今の自動採点はまだ粗く、次のモジュールで改善します。
  • 問題が起きたら何を見るのか? 各ステップのツール呼び出しはすべて表示されるので、何を検索し、どの行を読んだかを見ます。次のモジュールでこれをログとして記録します。
  • もっと安くできるか? まず v2 の固定の流れで答え、「ドキュメントに見つかりませんでした」のときだけエージェントを起動すればよいのです(第 1 課で触れた戦略)。
  • 本当にエージェントが必要か? 「答えがソースコードにある」質問には必要です。ほとんどのドキュメントの質問には実は不要で、それこそが一つ前の最適化の根拠です。

練習問題

  1. ファイルをまたいで追跡する必要のある質問を v3 にしてみてください。たとえば「httpx.get は最終的にどの関数で本当にネットワークリクエストを送っているのか?」。どこまで進めるか、結論は正しいかを見てください。
  2. 第 1 課で触れた混合戦略を実装してください。まず v2 の流れで進め、回答に「ドキュメントに見つかりませんでした」が出たら v3 のエージェントを起動します。8 問の評価問題の合計費用を比べてください。
  3. eval_agent.py に引数を加えて、各問題を 3 回実行し、問題ごとの正解回数を集計してください。どの問題が「安定して正解」で、どれが「正解したりしなかったり」でしょうか。

確認テスト

1. RepoBot にまずドキュメントを、それからソースコードを調べさせ、いきなりソースコードを調べさせないのはなぜですか?

ドキュメントが述べているのはユーザーに約束した使い方で、ソースコードには内部の実装の細部がたくさんあり、それが安定した外向きのふるまいとは限りません。しかもドキュメントはふつう 1 回の検索で足りますが、ソースコードを調べるには何度も検索し読む必要があり、より高く、遅くなります。ドキュメントに答えがないときだけ、ソースコードを探す必要があるのです。

2. 評価スクリプトは 8/8 で全問正解と表示しました。それでも回答を一つずつ見るのはなぜですか?

自動採点は間違えることがあるからです。この課の最初の実行では、ある問題の結論は間違っていたのに、説明の中にたまたまキーワードが出てきたので、正解と判定されました。人が見て初めて、採点のルールが信頼できるかどうかがわかり、それをもとに「現れてはいけない」条件を加えるなどしてルールを改善できます。

3. 複数のスレッドが同時にローカルの埋め込みモデルを呼び出すと、ほとんど固まるほど遅くなるのはなぜですか?どう解決しますか?

PyTorch は計算のたびに自分で複数のスレッドを立ち上げます。複数の Python スレッドが同時にモデルを呼び出すと、スレッドの数が何倍にも増えて CPU のコア数をはるかに超え、互いに資源を奪い合います。解決策はロックを一つ使い、同時にローカルモデルを呼び出せるスレッドを一つだけにすることです。1 回の計算はとても速いので、順番待ちをしても速度にはほとんど影響しません。

質問と議論

このレッスンでつまずいたところは、ここで質問してください。他の人の質問に答えるのも歓迎です。

質問で 3 ポイント、回答で 6 ポイント。審査を通過すると公開されます。

議論を読み込んでいます…