模块 06 · 第 4 课

省钱和提速

五个小实验:把固定资料放在前面省了 64% 的钱,要求简短让输出少了 92%,简单问题关掉思考,自己做结果缓存,并发把 10 个请求从 9.5 秒压到 2.3 秒。

  • 约 35 分钟
  • 难度:进阶
  • 实测:2026-09-14 deepseek-flash

一个 AI 应用上线后,每天被调用成千上万次,每次省下一点点,加起来就很可观;每次快一点点,用户的感受就会好很多。

这一课做五个小实验,每一个都是可以直接用到项目里的办法,每一个都有测出来的数字。实验都用 deepseek-flash,价格按 2026 年 9 月的高峰价计算。完整代码在 code/06-production/cost_latency.py

1. 固定的资料放在前面

第 01 模块第 4 课讲过 DeepSeek 的上下文缓存:请求的开头和之前某次请求一样,这部分就按缓存命中的价格收费,是未命中的五十分之一。

实验:三个不同的问题,每次都附上同一份 httpx 文档(大约 1.5 万词元)。一种写法把问题放在前面、文档放在后面;另一种把文档放进 system 消息、问题放在后面。

for label, build in [
    ("问题在前", lambda q: [{"role": "user", "content": f"问题:{q}\n\n参考文档:\n{DOCS}\n\n一句话回答。"}]),
    ("文档在前", lambda q: [{"role": "system", "content": f"参考文档:\n{DOCS}"}, {"role": "user", "content": q + "一句话回答。"}]),
]:
== 1. 缓存:固定的资料放在前面,还是问题放在前面
  问题在前:三次的缓存命中 ['0/15168', '0/15169', '0/15167'],共 0.01379 美元
  文档在前:三次的缓存命中 ['0/15167', '14976/15168', '14976/15166'],共 0.00500 美元

问题在前,三次一次都没命中:每个问题不同,请求的开头就不同,后面的文档再一样也没用。文档在前,第一次没有缓存,后两次各命中了 14976 个词元。总花费从 0.01379 美元降到 0.00500 美元,省了 64%。

这里只问了三次,第一次的"冷启动"占了大头。问的次数越多,文档在前的写法越接近"每次只为问题本身付费"。

只是调换了一下顺序,一行代码都不用多写。检查一下你的提示词:system 消息里有没有放当前时间、用户名、随机编号这类每次都会变的东西?把它们挪到最后。

2. 让模型少说点

输出的价格是输入的 4 倍,而且生成速度直接决定用户要等多久。

q = "httpx 和 requests 有什么区别?"
for label, prompt in [("不做要求", q), ("要求简短", q + "用三句话以内回答。")]:
== 2. 输出长度:不做要求 vs 要求简短
  不做要求:输出 650 词元,3.3 秒,0.00078 美元
  要求简短:输出 52 词元,0.8 秒,0.00007 美元

只加了一句"用三句话以内回答",输出从 650 个词元降到 52 个,费用降到十分之一,等待时间从 3.3 秒降到 0.8 秒。

模型默认倾向于写得很全面,这对一些场景是好事,对另一些场景是浪费。想清楚你的用户真正需要多少信息,在提示词里写明。还可以设置 max_tokens 作为硬性上限,防止个别情况下写个没完,但要记住它会直接截断回答(第 00 模块第 3 课),所以主要还是靠提示词。

3. 简单问题别开思考

== 3. 简单问题开不开思考
  不思考:输出 26 词元,0.7 秒,0.00004 美元 | '用 `params` 参数传字典,如 `httpx.get(url, param'
  思考:输出 146 词元,1.9 秒,0.00019 美元 | '用 `params` 参数传字典或元组列表,如 `httpx.get(url, '

"怎么传查询参数"这种问题,两种模式的回答几乎一样,开思考多花了将近 5 倍的钱、多等了 1.2 秒。第 02 模块第 3 课讲过,思考适合多步推理的问题。一个应用里的问题往往有难有易,可以按类型分开处理:简单的查询关掉思考,复杂的分析才打开。怎么判断难易,可以用下一课的分类器顺便做。

4. 相同的问题,直接返回上次的答案

很多应用里,用户会反复问同样的问题。与其每次都调用模型,不如把答案存起来:

cache = {}


def cached_ask(question):
    key = hashlib.sha256(" ".join(question.lower().split()).encode()).hexdigest()  # 忽略大小写和多余空格
    if key in cache:
        return cache[key], 0.0
    text, u, _ = ask([{"role": "user", "content": question}])
    cache[key] = text
    return text, cost(u)

问 4 次,写法略有不同:"httpx 怎么设置代理?"、同样一句再问一次、"HTTPX 怎么设置代理?"(大写,多了一个空格)、"httpx 怎么设置代理"(没有问号)。

== 4. 自己做结果缓存:同样的问题直接返回上次的答案
  问了 4 次(写法略有不同),实际调用 2 次模型,共 0.00239 美元

前三次被识别为同一个问题,只调用了一次模型。最后一次因为少了个问号,被当成了新问题。这说明规范化做得还不够:标点也应该去掉。练习 2 会让你改进它。

更进一步,还可以用第 01 模块第 5 课的向量嵌入做"语义缓存":意思相近的问题("怎么配代理"和"如何设置代理服务器")也命中同一个缓存。但要小心:意思相近不等于答案相同,"httpx 怎么设置超时"和"requests 怎么设置超时"的向量就非常接近。

什么时候不能用结果缓存:

  • 答案会变。依赖实时数据、依赖用户个人信息的回答,不能跨用户、跨时间共享。
  • 多轮对话。"那异步呢"这种问题,答案取决于上文,只看这一句不能缓存。
  • 需要多样性。写文案、起名字这种任务,用户本来就想要不一样的结果。

缓存要设过期时间。文档更新了,旧的答案就可能过时。

5. 并发

== 5. 串行 vs 并发
  10 个请求:一个一个来 9.5 秒,5 个并发 2.3 秒

10 个互相独立的请求,一个接一个发,要 9.5 秒;5 个同时发,只要 2.3 秒。模型调用的时间大部分花在等待服务器上,这段时间你的程序什么都没做,完全可以同时发出别的请求。

第 03 模块第 4 课讲过怎么用线程池加信号量控制并发数。并发不是越多越好:服务商有并发限制,超过了会返回 429;并发太多,你自己的机器和网络也可能扛不住。

其他办法

  • 换更小、更便宜的模型。第 01 模块第 6 课的方法:在你的评估集上比较,便宜的模型能达标就用便宜的。也可以按问题难度分流:简单的交给小模型,难的才交给大模型。
  • 避开高峰时段。DeepSeek 低谷时段的价格是高峰时段的一半(截至 2026 年 9 月,北京时间工作日 9:00~12:00 和 14:00~18:00 是高峰)。不着急的批量任务,比如夜里跑评估、处理积压的数据,放到低谷时段。
  • 减少不必要的上下文。RAG 取 5 块还是 3 块?对话历史保留 20 条还是 10 条?智能体的工具返回能不能再短一点?每一项都用评估集验证,确认效果不降再改。
  • 流式输出。它不省钱,但能让用户感觉快得多(第 03 模块第 2 课)。

先量,再改

这一课的每一个优化,都应该在你自己的应用里先量一量:现在的钱花在哪里、时间花在哪里?上一课的日志正好能回答这个问题。按费用排序,找出最贵的那一类请求,从它开始优化。优化之后,用评估集确认效果没有变差。省了钱却答错了,不划算。

练习

  1. 改进 cached_ask 的规范化:去掉所有标点和空白,统一全角半角。再跑一次实验,4 次提问是不是只调用一次模型了?
  2. 用第 3 课的日志(traces.jsonl),算一算智能体回答一个问题时,输入词元里有多大比例命中了缓存。想一想怎么调整消息的顺序能让命中率更高。
  3. 在第 2 个实验里,把"用三句话以内回答"换成 max_tokens=60,不改提示词。回答会是什么样子?哪种办法更好?

自测

1. 同样的文档和问题,为什么"文档在前"比"问题在前"便宜那么多?

DeepSeek 的缓存只认请求开头相同的部分。问题在前时,每次问题不同,开头就不同,后面的文档无法命中缓存,每次都按全价收费。文档在前时,开头的文档每次都一样,第一次之后都能命中缓存,按五十分之一的价格收费。

2. 什么情况下不能用结果缓存?

答案会随时间变化(依赖实时数据、文档会更新)、答案依赖用户个人信息、问题依赖对话上下文(比如"那异步呢")、以及用户本来就希望每次得到不同结果的任务(写文案、起名字)。另外缓存要设过期时间,避免返回过时的答案。

3. 并发请求为什么能大幅缩短总耗时?并发数是不是越大越好?

一次模型调用的大部分时间都在等服务器返回,程序在等待时是空闲的,同时发出多个请求就能把这些等待重叠起来。并发数不是越大越好:超过服务商的并发限制会被限流(429),自己的机器和网络也有承受上限,所以要用信号量之类的办法控制并发数。