專案:答疑助手的第一版
把多輪對話、流式輸出、重試和費用統計組裝成 RepoBot v1,一個 httpx 答疑助手。用 6 道有標準答案的題測它,看它答對了什麼、編造了什麼。
- 約 60 分鐘
- 難度:進階
- 實測:2026-09-14 deepseek-flash
程式碼和執行結果保留原樣(簡體中文),與實際執行時完全一致。
這個模組學的東西,單獨看都不難。這一課把它們組裝成一個完整的小程式:RepoBot v1,一個在命令列裡回答 httpx 問題的助手。它是整個第一部分貫穿專案的起點,後面幾個模組會一版一版地改進它。
做完的標準
動手之前先定好目標。做完這一課,下面幾條都應該成立:
- 執行
python repobot.py,能和它連續對話,它記得上一輪說過什麼。 - 回答是一個字一個字流式顯示出來的。
- 每輪結束後顯示輸入、輸出詞元數、快取命中數、本輪花費和累計花費。
- 問和 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 條訊息。答疑的場景裡,使用者很少連續問十幾輪還要引用最開始的內容。
執行
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.py、httpx/_config.py、httpx/_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 不開思考,一輪不到 0.001 美元。
- 真的需要智慧體嗎? 不需要。v1 只是一次呼叫加對話歷史。到第 05 模組,當它需要自己決定去查文件還是翻原始碼時,才考慮智慧體。
練習
- 用
--think開啟思考模式,再問一遍這 6 個問題。第 1 題和第 3 題答對了嗎?花費變成多少? - 刪掉 system 提示詞裡"不確定的地方要明確說……"那一條,再問第 4 題幾次,看看模型會不會重新開始編更新日誌。
- 自己出 5 道 httpx 的題,最好是你能從文件或原始碼裡查到標準答案的,測一測 v1,統計它答對幾道。把這 5 道題留著,下一個模組用 v2 再測一次。
自測
1. RepoBot 為什麼在開始輸出之後出錯就不再重試?
使用者已經看到了一部分回答。重試會從頭生成一段新的回答,內容和使用者看到的前半段很可能對不上,拼在一起會很混亂。所以只在建立連線階段(使用者還沒看到任何內容時)重試,輸出開始後出錯就提示使用者重新提問。
2. system 提示詞裡寫了"不確定就說不確定",為什麼 RepoBot 還是在第 1 題上編造了?
這條規則只有在模型"意識到"自己不確定時才會生效。第 1 題模型錯誤地以為自己知道答案,所以很肯定地給出了錯的回答。提示詞能減少編造,但不能根治。根治的辦法是讓模型照著真實的資料回答,也就是 RAG。
3. 為什麼 RepoBot 每一輪的快取命中數在不斷增加?
每一輪的請求都以相同的 system 提示詞和之前的對話歷史開頭。上一輪請求的內容,在這一輪成了開頭的一部分,伺服器可以複用之前算好的結果,所以命中的部分越來越多。