Projet : première version de l'assistant
Assembler conversation à plusieurs tours, streaming, nouvelles tentatives et suivi des coûts dans RepoBot v1, un assistant de questions-réponses sur httpx. Le tester avec 6 questions à réponse connue, et voir ce qu'il répond juste et ce qu'il invente.
- Environ 60 minutes
- Niveau : Intermédiaire
- Testé : 2026-09-14 deepseek-flash
Le code et les sorties des programmes sont reproduits tels qu’ils ont tourné : commentaires et sorties sont donc en chinois.
Pris séparément, rien de ce module n'est difficile. Cette leçon assemble tout en un petit programme complet : RepoBot v1, un assistant qui répond en ligne de commande aux questions sur httpx. C'est le point de départ du projet fil rouge de toute la première partie ; les modules suivants l'amélioreront version après version.
Critères d'achèvement
Avant de commencer, fixons l'objectif. À la fin de cette leçon, tout ceci doit être vrai :
- En lançant
python repobot.py, on peut converser avec lui en continu, et il se souvient du tour précédent. - La réponse s'affiche en streaming, caractère par caractère.
- À la fin de chaque tour, il affiche les tokens d'entrée et de sortie, les tokens servis depuis le cache, le coût du tour et le coût cumulé.
- Aux questions sans rapport avec httpx, il refuse poliment.
- En cas de coupure réseau ou d'erreur côté serveur, le programme ne plante pas, mais invite à reposer la question.
- Vous savez dire sur quels types de questions il se trompe, et pourquoi.
Le dernier point est le plus important. La v1 est volontairement imparfaite : c'est en voyant clairement ses défauts qu'on sait ce que la v2 doit résoudre.
Structure
Le code complet se trouve dans projects/repobot/v1/repobot.py, un peu plus de cent lignes, en quatre blocs :
repobot.py
SYSTEM system 提示词:身份、范围、规则
cost_usd 根据 usage 算钱(01 模块第 4 课)
open_stream 发起流式请求,连接阶段出错自动重试(本模块第 2、4 课)
answer 流式打印回答,拼出完整文本,检查是否被截断(本模块第 2 课)
main 多轮对话循环,维护历史、打印花费(本模块第 1 课)
Chaque bloc a été vu dans les leçons précédentes ; seuls les nouveaux problèmes posés par l'assemblage sont expliqués ci-dessous.
Le prompt system
SYSTEM = """你是 RepoBot,Python HTTP 客户端库 httpx 的答疑助手。
- 只回答和 httpx 有关的问题,包括它的用法、原理、报错排查,以及和 requests 等库的比较。
- 和 httpx 无关的问题,礼貌地说明你只负责 httpx,不要回答。
- 回答要简洁,能用代码说明的就给代码。
- 不确定的地方要明确说"我不确定",不要编造版本号、参数名或者更新日志。"""
Quatre règles, chacune correspondant à un problème concret (la méthode de la leçon 1 du module 02) : la première délimite le périmètre ; la deuxième l'empêche de devenir un assistant généraliste qui parle de tout, ce qui est à la fois un choix de positionnement du produit et une maîtrise des coûts ; la troisième contrôle la longueur et la forme des réponses ; la quatrième vise les hallucinations. Le test ci-dessous nous dira si la quatrième sert vraiment.
Combiner streaming et nouvelles tentatives
Le call_llm de la leçon 4 était écrit pour des appels sans streaming. Le streaming ajoute une difficulté : une erreur peut survenir alors que la moitié de la réponse est déjà affichée.
RepoBot sépare l'appel en streaming en deux phases :
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)
Si l'erreur survient pendant l'établissement de la connexion (limitation de débit, erreur serveur, connexion impossible), l'utilisateur n'a encore rien vu, et on peut réessayer sans crainte. Une fois la sortie commencée, une erreur en cours de route n'est plus réessayée, car la réponse régénérée ne correspondrait pas à la première moitié déjà vue. main intercepte cette erreur, dit à l'utilisateur « ce tour est annulé, vous pouvez reposer la question », et n'écrit pas ce tour dans l'historique :
try:
text, usage = answer(messages)
except openai.APIError as e:
print(f"\n[出错了:{type(e).__name__},这一轮作废,可以再问一次]")
continue
La fonction answer affiche au fur et à mesure en stockant chaque morceau de texte dans une liste, puis assemble la réponse complète pour l'écrire dans l'historique. Elle note aussi finish_reason ; si c'est length, elle prévient l'utilisateur que la réponse a été tronquée.
L'historique utilise la troncature la plus simple : seuls les 20 derniers messages sont gardés. En questions-réponses, un utilisateur pose rarement une douzaine de questions d'affilée en se référant encore au tout début.
Exécution
cd projects/repobot/v1
pip install -r requirements.txt
python repobot.py
J'ai préparé 6 questions pour le tester. Cinq portent sur httpx et ont toutes une réponse type, que j'ai vérifiée au préalable dans le code source de httpx ; la dernière est sans rapport avec httpx. Je les lui donne toutes par un tube :
printf '%s\n' "httpx 默认会自动跟随重定向吗?" "那默认最多跟随几次重定向?" \
"httpx 的 Client 默认连接池最多允许多少个连接?keep-alive 连接最多多少个?" \
"httpx 0.27.2 这个版本修复了什么问题?" "怎么给 httpx 设置 10 秒的超时?给一行代码。" \
"今天北京天气怎么样?" | python repobot.py
Voici la sortie complète que j'ai obtenue, sans un mot changé ; j'ai seulement supprimé quelques exemples de code trop longs. La formulation de vos réponses sera différente, et même le fait qu'elles soient justes ou fausses pourra varier.
你: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 美元]
Vérification question par question
J'ai vérifié les réponses phrase par phrase dans le code source de httpx (httpx/_client.py, httpx/_config.py, httpx/_exceptions.py).
Question 1 : faux. Le Client de httpx contient follow_redirects: bool = False : par défaut, il ne suit pas les redirections, et c'est justement une différence importante avec requests. Non seulement le modèle se trompe, mais il invente une histoire de versions (« passé à True après la 0.20 ») pour étayer sa mauvaise réponse. Il ajoute bien « je ne suis pas sûr » à la fin, mais le ton qui précède est très assuré ; l'utilisateur le croira probablement.
Question 2 : juste. Le code source contient DEFAULT_MAX_REDIRECTS = 20. TooManyRedirects hérite bien de RequestError. (Je n'ai pas vérifié dans le code de requests le fait que requests suit 30 redirections par défaut, donc cela ne compte pas.)
Question 3 : chiffres justes, explication inventée. Le code source contient DEFAULT_LIMITS = Limits(max_connections=100, max_keepalive_connections=20) et keepalive_expiry vaut 5 secondes par défaut : les trois chiffres sont justes. Mais « max_keepalive_connections vaut par défaut 20 % de max_connections, et si l'on ne définit que max_connections, il suit la proportion » est inventé : dans la classe Limits, max_keepalive_connections vaut None par défaut, sans aucune logique de proportion. Ce type de « bons chiffres avec un principe inventé » est le plus difficile à repérer.
Question 4 : pas d'invention. À la leçon 2 du module 01, pour la même question, le modèle avait inventé une faille de sécurité inexistante avec un numéro de CVE. Cette fois, il dit « je ne suis pas sûr » et indique où chercher. La différence tient à cette phrase du prompt system : « là où tu n'es pas sûr, dis clairement “je ne suis pas sûr”, n'invente pas de numéro de version, de nom de paramètre ni de journal de modifications ». Cette règle est utile, mais les questions 1 et 3 montrent qu'elle n'arrête pas toutes les inventions : le modèle doit d'abord « se rendre compte » qu'il n'est pas sûr pour que la règle s'applique.
Question 5 : juste.
Question 6 : refus tout à fait correct.
Regardons aussi la facture : 6 tours pour 0,00167 dollar au total. Le nombre de tokens servis depuis le cache augmente à chaque tour (0, 128, 384, 768, 1024), car le prompt system et l'historique précédent forment un début fixe : le cache de la leçon 4 du module 01 agit automatiquement.
D'où viennent les défauts de la v1
6 questions : 2 parfaitement justes, 1 refus correct, 1 aveu honnête d'ignorance, 1 aux chiffres justes mais avec une explication inventée, 1 complètement fausse avec une justification inventée.
La cause profonde est unique : il ne peut répondre que de mémoire. Les données d'entraînement du modèle contiennent énormément de choses sur httpx et sur requests, et comme leurs usages se ressemblent, les souvenirs se mélangent. La question 1 attribue très probablement à httpx le comportement de requests.
Pour résoudre ce problème, retoucher le prompt a un effet limité. La vraie solution consiste à lui faire consulter d'abord la documentation officielle de httpx avant de répondre, à répondre d'après la documentation, et à indiquer à l'utilisateur de quelle page vient la réponse. C'est le RAG du module suivant.
Les questions auxquelles ce projet doit répondre
À chaque version terminée, vérifiez votre conception avec ces questions :
- Pourquoi cette conception ? Ligne de commande + streaming + historique tronqué : c'est la forme la plus simple qui fonctionne. D'abord quelque chose d'utilisable, puis on ajoute des fonctions petit à petit.
- Où va-t-il échouer ? Valeurs par défaut peu connues, différences entre versions, points semblables à requests mais différents : il s'y trompe facilement, et avec assurance.
- Comment savoir s'il est bon ? Pour l'instant, seulement 6 questions vérifiées à la main. Le module 06 en fait un jeu d'évaluation exécutable automatiquement.
- Que regarder en cas de problème ? Pour l'instant, seulement la sortie à l'écran. Le module 06 ajoute des journaux.
- Peut-on faire moins cher ? C'est déjà très bon marché : flash sans réflexion, moins de 0,001 dollar par tour.
- Faut-il vraiment un agent ? Non. La v1 n'est qu'un appel avec historique de conversation. Au module 05, quand il devra décider lui-même s'il consulte la documentation ou fouille le code source, on envisagera un agent.
Exercices
- Activez la réflexion avec
--thinket reposez les 6 questions. Les questions 1 et 3 sont-elles justes ? Combien cela coûte-t-il désormais ? - Supprimez du prompt system la règle « là où tu n'es pas sûr, dis clairement… », reposez plusieurs fois la question 4, et voyez si le modèle recommence à inventer des journaux de modifications.
- Écrivez vous-même 5 questions sur httpx, de préférence dont vous pouvez trouver la réponse type dans la documentation ou le code source, testez la v1 et comptez ses bonnes réponses. Gardez ces 5 questions : au module suivant, vous les poserez à la v2.
Auto-test
1. Pourquoi RepoBot ne réessaie-t-il plus quand une erreur survient après le début de la sortie ?
L'utilisateur a déjà vu une partie de la réponse. Réessayer génère une nouvelle réponse depuis le début, dont le contenu ne correspondra probablement pas à la première moitié déjà vue ; l'assemblage serait confus. On ne réessaie donc que pendant l'établissement de la connexion (quand l'utilisateur n'a encore rien vu) ; après le début de la sortie, une erreur invite l'utilisateur à reposer la question.
2. Le prompt system dit « si tu n'es pas sûr, dis-le ». Pourquoi RepoBot a-t-il quand même inventé à la question 1 ?
Cette règle ne s'applique que si le modèle « se rend compte » qu'il n'est pas sûr. À la question 1, il croyait à tort connaître la réponse, et a donc donné une mauvaise réponse avec assurance. Le prompt réduit les inventions sans les éliminer. La solution de fond est de faire répondre le modèle d'après de vrais documents, c'est-à-dire le RAG.
3. Pourquoi le nombre de tokens servis depuis le cache augmente-t-il à chaque tour de RepoBot ?
Chaque requête commence par le même prompt system et l'historique de conversation précédent. Le contenu de la requête du tour précédent fait partie du début de celle du tour suivant, et le serveur peut réutiliser les calculs déjà faits ; la partie servie depuis le cache grandit donc.
Questions et discussion
Bloqué sur cette leçon ? Posez votre question ici. Et si vous pouvez répondre à quelqu'un, n'hésitez pas.
Une question rapporte 3 points, une réponse 6. Les messages paraissent après vérification.
Chargement de la discussion…