モジュール 03 · 第 5 回

プロジェクト:Q&A アシスタントの第 1 版

マルチターン会話、ストリーミング出力、リトライ、費用の集計を組み合わせて、httpx の Q&A アシスタント RepoBot v1 を作ります。正解のある 6 問でテストし、何に正しく答え、何をでっち上げたかを見ます。

  • 約 60 分
  • 難易度:中級
  • 検証:2026-09-14 deepseek-flash

コードと実行結果は実際に動かしたときのまま載せているため、コメントと出力は中国語です。

このモジュールで学んだことは、一つずつ見ればどれも難しくありません。この課ではそれらを組み立てて、一つの完全な小さなプログラムにします。RepoBot v1、コマンドラインで httpx の質問に答えるアシスタントです。これは第 1 部全体を貫くプロジェクトの出発点で、この後のいくつかのモジュールで一版ずつ改良していきます。

完成の目安

手を動かす前に、まず目標を決めておきます。この課を終えたら、次のことがすべて成り立っているはずです。

  • python repobot.py を実行すると、続けて会話ができ、前のターンで話したことを覚えている。
  • 回答が 1 文字ずつストリーミングで表示される。
  • 各ターンの終わりに、入力と出力のトークン数、キャッシュヒット数、そのターンの費用、累計費用を表示する。
  • httpx と関係のない質問には、丁寧に断る。
  • ネットワークが切れたりサーバー側でエラーが起きたりしても、プログラムは落ちず、もう一度質問するよう促す。
  • どんな種類の質問で間違えるのか、そしてなぜなのかを説明できる。

最後の項目が最も重要です。v1 はわざと不完全に作ってあります。その問題をはっきり見て初めて、v2 で何を解決すべきかがわかります。

構成

完全なコードは projects/repobot/v1/repobot.py にあり、全部で百数十行、四つのブロックからできています。

repobot.py
  SYSTEM        system 提示词:身份、范围、规则
  cost_usd      根据 usage 算钱(01 模块第 4 课)
  open_stream   发起流式请求,连接阶段出错自动重试(本模块第 2、4 课)
  answer        流式打印回答,拼出完整文本,检查是否被截断(本模块第 2 课)
  main          多轮对话循环,维护历史、打印花费(本模块第 1 课)

どのブロックもこれまでの課で扱ったものなので、以下では組み立てるときに考えるべき新しい問題だけを扱います。

system プロンプト

SYSTEM = """你是 RepoBot,Python HTTP 客户端库 httpx 的答疑助手。

- 只回答和 httpx 有关的问题,包括它的用法、原理、报错排查,以及和 requests 等库的比较。
- 和 httpx 无关的问题,礼貌地说明你只负责 httpx,不要回答。
- 回答要简洁,能用代码说明的就给代码。
- 不确定的地方要明确说"我不确定",不要编造版本号、参数名或者更新日志。"""

四つのルールは、それぞれ具体的な問題に対応しています(モジュール 02 第 1 課で扱った書き方です)。一つ目は範囲を決めます。二つ目は何でも話す汎用アシスタントになるのを防ぎます。これは製品の位置づけであると同時に、コストの管理でもあります。三つ目は回答の長さと形を管理します。四つ目はハルシネーション対策です。四つ目が本当に効くのかどうかは、この後のテストが教えてくれます。

ストリーミングとリトライをどう組み合わせるか

第 4 課の call_llm は非ストリーミングの呼び出し向けに書いたものです。ストリーミングの呼び出しにはもう一つ面倒があります。出力が半分進んだところでエラーが起きることがあるのです。

RepoBot は、ストリーミングの呼び出しを二つの段階に分けて対処します。

def open_stream(messages, max_attempts=4):
    """发起流式请求。连接阶段出错会自动重试;开始输出之后再出错,就不重试了。"""
    for attempt in range(1, max_attempts + 1):
        try:
            return client.chat.completions.create(
                model=MODEL,
                messages=messages,
                stream=True,
                stream_options={"include_usage": True},
                max_tokens=4000,
                extra_body={"thinking": {"type": "enabled" if THINKING else "disabled"}},
            )
        except RETRYABLE as e:
            if attempt == max_attempts:
                raise
            wait = 2 ** (attempt - 1) + random.random()
            print(f"\n[{type(e).__name__},{wait:.1f} 秒后重试]", file=sys.stderr)
            time.sleep(wait)

接続を確立するときのエラー(レート制限、サーバーエラー、接続できない)なら、ユーザーはまだ何も見ていないので、安心してリトライできます。いったん出力が始まったら、途中のエラーではリトライしません。生成し直した回答は、ユーザーがすでに見た前半と食い違うからです。main はこの種のエラーを捕まえて、ユーザーに「このターンは無効です。もう一度質問できます」と伝え、このターンを履歴に書き込みません。

        try:
            text, usage = answer(messages)
        except openai.APIError as e:
            print(f"\n[出错了:{type(e).__name__},这一轮作废,可以再问一次]")
            continue

answer 関数は表示しながら各断片の文字をリストにためていき、最後につなげて完全な回答にし、それを履歴に書き込みます。finish_reason も記録していて、それが length なら、回答が打ち切られたことをユーザーに知らせます。

履歴には最も簡単な切り詰めの方法を使い、最近の 20 件のメッセージだけを残します。Q&A の場面では、ユーザーが十数ターンも続けて質問しながら一番最初の内容を参照することはまずありません。

実行する

cd projects/repobot/v1
pip install -r requirements.txt
python repobot.py

テスト用に 6 つの質問を用意しました。そのうち 5 つは httpx に関するもので、どれも正解があり、事前に httpx のソースコードで確認してあります。最後の一つは httpx と関係のない質問です。パイプでまとめて渡します。

printf '%s\n' "httpx 默认会自动跟随重定向吗?" "那默认最多跟随几次重定向?" \
  "httpx 的 Client 默认连接池最多允许多少个连接?keep-alive 连接最多多少个?" \
  "httpx 0.27.2 这个版本修复了什么问题?" "怎么给 httpx 设置 10 秒的超时?给一行代码。" \
  "今天北京天气怎么样?" | python repobot.py

以下は私が実行して得た完全な出力で、一字も変えておらず、長すぎるコード例をいくつか削除しただけです。あなたの回答は言い回しが違い、正誤さえ違うかもしれません。

你:httpx 默认会自动跟随重定向吗?
RepoBot:是的,httpx 默认会自动跟随重定向(`follow_redirects=True` 是默认行为)。

注意和 requests 的区别:**requests 默认跟随,httpx 也是默认跟随**,但 httpx 早期版本默认是 `False`,0.20 之后改成了 `True`。如果你用的是很老的版本,行为可能不同。
(代码示例省略)
具体默认值建议以你所用版本的文档为准,我不确定所有版本都是 `True`。
[输入 118(缓存命中 0),输出 219,本轮 0.00030 美元,累计 0.00030 美元]

你:那默认最多跟随几次重定向?
RepoBot:默认最多跟随 **20 次**。
(代码示例省略)
补充一点:httpx 的 `TooManyRedirects` 继承自 `RequestError`,可以直接用 `httpx.RequestError` 捕获。

这个 `20` 是 httpx 的默认值,和 requests 的默认 `30` 不同。我不确定该默认值在所有历史版本里都是 20,建议以你所用版本的文档为准。
[输入 349(缓存命中 128),输出 221,本轮 0.00033 美元,累计 0.00063 美元]

你:httpx 的 Client 默认连接池最多允许多少个连接?keep-alive 连接最多多少个?
RepoBot:`httpx.Client` 的默认连接池上限是 **100 个连接**,其中默认 **20 个是 keep-alive 连接**。
(代码示例省略)
- `max_keepalive_connections`:池中保留的、可复用的空闲连接数上限,默认为 `max_connections` 的 20%(即 100 的 20% = 20)。如果显式只设 `max_connections`,这个值会按比例走。
- `keepalive_expiry`:空闲 keep-alive 连接的存活时间,默认 5 秒。
(后面还有两条补充说明,省略)
[输入 598(缓存命中 384),输出 405,本轮 0.00055 美元,累计 0.00118 美元]

你:httpx 0.27.2 这个版本修复了什么问题?
RepoBot:我不确定 httpx 0.27.2 具体修复了哪些问题,不想凭印象编造更新日志。

建议你直接看官方来源核实:

- 发布记录:https://github.com/encode/httpx/releases
- 更新日志文件:https://github.com/encode/httpx/blob/master/CHANGELOG.md

在 Releases 页面里找到 `0.27.2` 那个 tag,就能看到该版本的具体改动。如果你把 changelog 内容贴过来,我可以帮你解读这些改动对你用法的实际影响。
[输入 1022(缓存命中 768),输出 119,本轮 0.00022 美元,累计 0.00141 美元]

你:怎么给 httpx 设置 10 秒的超时?给一行代码。
RepoBot:```python
r = httpx.get("https://example.com", timeout=10.0)
```
(后面还有两种写法,省略)
[输入 1162(缓存命中 1024),输出 81,本轮 0.00014 美元,累计 0.00155 美元]

你:今天北京天气怎么样?
RepoBot:抱歉,我只负责 httpx 相关的问题,天气这类问题我答不了。如果你有 httpx 用法、报错或配置方面的问题,我很乐意帮忙。
[输入 1252(缓存命中 1024),输出 35,本轮 0.00012 美元,累计 0.00167 美元]

一問ずつ照合する

httpx のソースコード(httpx/_client.pyhttpx/_config.pyhttpx/_exceptions.py)で、回答を一文ずつ確認しました。

第 1 問、不正解。httpx の Client には follow_redirects: bool = False と書かれていて、既定ではリダイレクトに従いません。これこそが requests との重要な違いの一つです。モデルは間違えただけでなく、「0.20 以降は True に変わった」というバージョンの経緯までこしらえて、誤った答えを支えています。最後に「確かではありません」と一言添えていますが、それまでの口調は非常に断定的で、ユーザーはたいてい信じてしまうでしょう。

第 2 問、正解。ソースコードには DEFAULT_MAX_REDIRECTS = 20 とあります。TooManyRedirects は確かに RequestError を継承しています。(requests の既定が 30 回だという点は requests のソースコードで確認していないので、評価に含めていません。)

第 3 問、数字は正しいが、説明はでっち上げ。ソースコードには DEFAULT_LIMITS = Limits(max_connections=100, max_keepalive_connections=20) とあり、keepalive_expiry の既定は 5 秒で、この三つの数字はすべて正しいです。しかし「max_keepalive_connections の既定は max_connections の 20% で、max_connections だけを設定すると比例して決まる」というのはでっち上げです。Limits クラスの max_keepalive_connections の既定値は None で、比例計算のロジックはどこにもありません。この種の「正しい数字に、でっち上げの原理を組み合わせたもの」が最も見抜きにくいのです。

第 4 問、でっち上げなし。モジュール 01 第 2 課では、同じ質問に対してモデルは存在しないセキュリティ脆弱性と CVE 番号をこしらえました。今回は「確かではありません」と言い、どこで調べればよいかを示しました。違いは system プロンプトの「不確かなところは『確かではありません』とはっきり言い、バージョン番号、パラメータ名、更新履歴をでっち上げない」という一文です。このルールは役に立ちますが、第 1 問と第 3 問からわかるように、すべてのでっち上げを防げるわけではありません。モデルがまず自分は不確かだと「気づいて」いなければ、このルールは働かないのです。

第 5 問、正解

第 6 問、上手に断った

請求書も見ておきましょう。6 ターンで合計 0.00167 ドルでした。各ターンのキャッシュヒット数は増え続けています(0、128、384、768、1024)。system プロンプトとそれまでの会話履歴が固定の冒頭になっていて、モジュール 01 第 4 課で扱ったキャッシュが自動的に働いているからです。

v1 の問題はどこにあるか

6 問のうち、完全に正解が 2 問、上手に断ったのが 1 問、正直にわからないと言ったのが 1 問、数字は正しいがでっち上げの説明が紛れ込んでいたのが 1 問、完全に間違えたうえに理由までこしらえたのが 1 問でした。

根本的な原因は一つだけです。記憶に頼って答えるしかないことです。モデルの学習データには httpx と requests についての内容が大量にあり、しかも両者の使い方はよく似ているので、記憶が混ざってしまいます。第 1 問は、おそらく requests のふるまいを httpx に当てはめてしまったのでしょう。

この問題を解決するのに、プロンプトの調整でできることは限られています。本当の解決策は、答える前にまず httpx の公式ドキュメントを調べさせ、ドキュメントに沿って答えさせ、答えがどのページから来たのかをユーザーに伝えさせることです。それが次のモジュールの RAG です。

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

版を一つ作り終えるたびに、次の問いで自分の設計を点検してください。

  • なぜこの設計にしたのか? コマンドライン + ストリーミング + 履歴の切り詰めは、動くものとして最も単純な形です。まず使えるものを作り、それから少しずつ機能を加えます。
  • どこで失敗するのか? マイナーな既定値、バージョンの違い、requests と似ているが違うところは間違えやすく、しかも間違えるときの口調は断定的です。
  • 良し悪しはどうやってわかるのか? 今のところ、手で照合した 6 問だけです。モジュール 06 でこれを自動で実行できる評価セットに拡張します。
  • 問題が起きたら何を見るのか? 今のところ画面の出力だけです。モジュール 06 でログを加えます。
  • もっと安くできるか? すでに十分安いです。flash で思考をオフにすれば、1 ターン 0.001 ドル未満です。
  • 本当にエージェントが必要か? 必要ありません。v1 は 1 回の呼び出しに会話履歴を加えただけです。モジュール 05 で、ドキュメントを調べるかソースコードを調べるかを自分で決める必要が出てきたとき、初めてエージェントを検討します。

練習問題

  1. --think で思考モードをオンにして、この 6 問をもう一度聞いてください。第 1 問と第 3 問は正解しましたか。費用はいくらになりましたか。
  2. system プロンプトから「不確かなところははっきり言う……」のルールを削除し、第 4 問を何回か聞いて、モデルが再び更新履歴をでっち上げ始めるか見てください。
  3. httpx の問題を自分で 5 問作ってください。できればドキュメントやソースコードで正解を確かめられるものにし、v1 でテストして何問正解するか数えます。この 5 問は取っておいて、次のモジュールで v2 でもう一度テストしましょう。

確認テスト

1. RepoBot が、出力が始まった後のエラーではリトライしないのはなぜですか?

ユーザーはすでに回答の一部を見ています。リトライすると最初から新しい回答が生成され、その内容はユーザーが見た前半とおそらく食い違い、つなげると混乱してしまいます。ですから接続を確立する段階(ユーザーがまだ何も見ていないとき)でだけリトライし、出力が始まってからのエラーでは、もう一度質問するようユーザーに促します。

2. system プロンプトに「不確かなら不確かだと言う」と書いたのに、RepoBot が第 1 問ででっち上げたのはなぜですか?

このルールは、モデルが自分は不確かだと「気づいて」いるときにしか働きません。第 1 問では、モデルは答えを知っていると誤って思い込んでいたので、自信を持って誤った回答をしました。プロンプトででっち上げを減らすことはできても、根絶はできません。根絶する方法は、モデルに本物の資料に沿って答えさせること、つまり RAG です。

3. RepoBot の各ターンのキャッシュヒット数が増え続けるのはなぜですか?

各ターンのリクエストは、同じ system プロンプトとそれまでの会話履歴で始まります。前のターンのリクエストの内容が、このターンでは冒頭の一部になるので、サーバーは以前に計算した結果を再利用でき、ヒットする部分がどんどん増えていくのです。

質問と議論

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

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

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