模块 03 · 第 5 课

项目:答疑助手的第一版

把多轮对话、流式输出、重试和费用统计组装成 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.pyhttpx/_config.pyhttpx/_exceptions.py)逐句核对了回答。

第 1 题,答错了。httpx 的 Client 里写的是 follow_redirects: bool = False,默认不跟随重定向,这正是它和 requests 的一个重要区别。模型不但答错,还编了一个"0.20 之后改成了 True"的版本历史来支撑错误的答案。它在结尾加了一句"我不确定",但前面的语气是非常肯定的,用户多半会信。

第 2 题,对了。源码里 DEFAULT_MAX_REDIRECTS = 20TooManyRedirects 确实继承自 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 模块,当它需要自己决定去查文档还是翻源码时,才考虑智能体。

练习

  1. --think 开启思考模式,再问一遍这 6 个问题。第 1 题和第 3 题答对了吗?花费变成多少?
  2. 删掉 system 提示词里"不确定的地方要明确说……"那一条,再问第 4 题几次,看看模型会不会重新开始编更新日志。
  3. 自己出 5 道 httpx 的题,最好是你能从文档或源码里查到标准答案的,测一测 v1,统计它答对几道。把这 5 道题留着,下一个模块用 v2 再测一次。

自测

1. RepoBot 为什么在开始输出之后出错就不再重试?

用户已经看到了一部分回答。重试会从头生成一段新的回答,内容和用户看到的前半段很可能对不上,拼在一起会很混乱。所以只在建立连接阶段(用户还没看到任何内容时)重试,输出开始后出错就提示用户重新提问。

2. system 提示词里写了"不确定就说不确定",为什么 RepoBot 还是在第 1 题上编造了?

这条规则只有在模型"意识到"自己不确定时才会生效。第 1 题模型错误地以为自己知道答案,所以很肯定地给出了错的回答。提示词能减少编造,但不能根治。根治的办法是让模型照着真实的资料回答,也就是 RAG。

3. 为什么 RepoBot 每一轮的缓存命中数在不断增加?

每一轮的请求都以相同的 system 提示词和之前的对话历史开头。上一轮请求的内容,在这一轮成了开头的一部分,服务器可以复用之前算好的结果,所以命中的部分越来越多。