
A8 讲了上线的基本功:估算成本、错误重试、保护 Key。应用真有人用以后,问题会变成:用户说「刚才那次回答又慢又错」,你能找到那次请求吗?这周费用涨了一倍,是哪个功能、哪个模型花的?换了新提示词,到底变好还是变坏了? 这一课把这些补上。
链路追踪:一次请求,一个 trace

一个用户请求里可能有好几次模型调用和工具调用(H5 的工作流、H7 的 Agent)。给每个请求生成一个 trace id,请求里的每一次模型调用或工具调用记一条 span:
| 字段 | 内容 |
|---|---|
trace_id | 同一请求的所有 span 共用 |
span | 这一步叫什么:route、retrieve、answer、tool:search_docs |
model、版本 | gpt-5.5、提示词 support-v3 |
| token | 输入、输出、缓存命中的输入 |
| 耗时、费用 | 毫秒;按单价算出的金额 |
| 错误 | 异常类型和信息,没出错为空 |
import json, os, re, time, uuid
from openai import OpenAI
client = OpenAI(base_url="https://hivegpt.cn/v1", api_key=os.environ["HIVEGPT_API_KEY"], timeout=60)
# 每百万 token 的输入 / 输出单价(元)。示意数字,请按 HiveGPT 模型列表里的实际单价填写
PRICE = {"gpt-5.5": (10.0, 40.0), "gpt-5.4-mini": (1.0, 4.0)}
def redact(text):
text = re.sub(r"\d{17}[\dXx]", "[证件号]", text)
text = re.sub(r"1[3-9]\d{9}", "[手机号]", text)
return re.sub(r"[\w.+-]+@[\w-]+\.[\w.-]+", "[邮箱]", text)
def write_span(span):
with open("traces.jsonl", "a", encoding="utf-8") as f: # 正式环境写进日志系统或数据库
f.write(json.dumps(span, ensure_ascii=False) + "\n")
def traced_chat(trace_id, span_name, **kwargs):
span = {"trace_id": trace_id, "span": span_name, "model": kwargs["model"], "error": None,
"input": redact(str(kwargs["messages"][-1]["content"]))[:300]} # 只存脱敏、截断后的片段
t0 = time.time()
try:
resp = client.chat.completions.create(**kwargs)
u = resp.usage
details = getattr(u, "prompt_tokens_details", None)
p_in, p_out = PRICE.get(kwargs["model"], (0, 0))
span.update(input_tokens=u.prompt_tokens, output_tokens=u.completion_tokens,
cached_tokens=(details.cached_tokens or 0) if details else 0,
cost=round((u.prompt_tokens * p_in + u.completion_tokens * p_out) / 1e6, 6))
return resp
except Exception as e:
span["error"] = f"{type(e).__name__}: {e}"
raise
finally:
span["ms"] = int((time.time() - t0) * 1000)
write_span(span)
trace_id = uuid.uuid4().hex # 每个用户请求生成一个,和回答一起返回给前端工具调用也用 write_span 记一条(span 写 tool:工具名,记耗时和是否失败)。量大了以后可以换成 OpenTelemetry 这类标准方案,字段思路一样。
存提示词要小心:
- 写入前脱敏手机号、邮箱、证件号;不要记录 API Key 和请求头。
- 默认只存截断后的片段;确实需要全文排查时,放在权限更严、保存期更短的地方,到期删除。
- 在隐私说明里告诉用户你会记录什么。
看板和告警
把 span 按时间汇总,至少看这几项:
| 指标 | 怎么算 | 告警示例 |
|---|---|---|
| 错误率 | 出错的请求 ÷ 总请求,按 5 分钟统计 | 超过 2% 并持续 10 分钟 |
| p95 延迟 | 95% 的请求在这个时间内完成 | 比上周同一时段高 50% |
| 每日费用 | 所有 span 的 cost 之和 | 超过日预算的 80% |
| 工具失败率 | 失败的工具调用 ÷ 工具调用总数 | 某个工具超过 10% |
- 看 p95,不只看平均:平均 2 秒,可能掩盖了一成请求要等 15 秒。
- 按模型、功能、提示词版本分组看,才知道问题出在哪一块。
- 告警要少而准:每条告警都得有人处理,响得太多,大家很快就不看了。
收集用户反馈
回答下面放 👍 / 👎,点击时把 trace id 一起发回后端:
@app.post("/api/feedback") # app 是 A8 里的 FastAPI 应用
def feedback(body: dict): # {"trace_id": "...", "good": false, "comment": "价格答错了"}
write_span({"trace_id": body["trace_id"], "span": "feedback", "good": bool(body["good"]),
"comment": redact(str(body.get("comment", "")))[:500]})
return {"ok": True}每周把 👎 的 trace 拿出来看:是检索没找到、模型理解错,还是工具失败?挑典型的写上正确答案,加进 H2 的评估集。线上出过的错,就成了以后每次改动都要通过的测试。👎 的比例也放进看板,按版本对比。
缓存
精确缓存:同样的问题直接返回
import hashlib
CACHE = {} # 演示用字典;正式环境用 Redis:SET key value EX ttl
PROMPT_VERSION = "faq-v2"
def cached_answer(trace_id, messages, model="gpt-5.5", ttl=3600):
raw = json.dumps([model, PROMPT_VERSION, messages], ensure_ascii=False, sort_keys=True)
key = hashlib.sha256(raw.encode()).hexdigest()
hit = CACHE.get(key)
if hit and hit["expires"] > time.time():
write_span({"trace_id": trace_id, "span": "cache", "hit": True})
return hit["text"]
resp = traced_chat(trace_id, "answer", model=model, messages=messages, max_completion_tokens=500)
text = resp.choices[0].message.content
CACHE[key] = {"text": text, "expires": time.time() + ttl}
return text- key 里包含模型、提示词版本和完整消息:改了提示词,旧缓存自然不再命中。用户问题先去掉首尾空格再算 key。
- 一定要设 TTL:资料会更新,价格、活动这类内容缓存时间要短。
- 只缓存和用户无关的回答(FAQ、产品说明)。带个人数据、依赖当前时间的回答不要缓存,更不能把 A 用户的回答返回给 B。
- 看命中率:命中率很低,说明问题很分散,精确缓存帮不上忙,就别加这层复杂度。
提示词缓存:让上游复用相同的开头
不少上游会缓存「和之前请求开头完全相同」的部分,命中的输入 token 更便宜、更快。能不能命中,取决于消息怎么排:
messages = [
{"role": "system", "content": SYSTEM_PROMPT + "\n\n" + PRODUCT_DOCS}, # 长且固定:放在开头,一个字都别变
*history[-6:], # 之前的对话,只往后追加
{"role": "user", "content": question}, # 每次都变:放最后
]
resp = traced_chat(trace_id, "answer", model="gpt-5.5", messages=messages)
# span 里 cached_tokens > 0 说明开头命中了缓存;上游没有报告时这个字段为空,按未命中处理- 长而固定的放前面:system 提示词、工具定义、固定资料。
- 会变的放最后:用户问题、检索结果、当前时间。
- 开头不要放每次都变的东西(时间戳、随机编号、用户名):开头差一个字,后面就都复用不了。
模型路由:简单的给便宜模型

大部分请求其实很简单。简单的交给便宜、快的模型,难的交给强模型,成本和延迟都能降下来。怎么分:
- 先用规则:很短的寒暄、命中 FAQ 关键词的,直接算简单;规则不花钱。
- 规则判断不了,再用便宜模型分类,用结构化输出返回档位和建议的输出上限。
- 拿不准就选强模型:把难题交给弱模型答错,代价通常比多花一点钱大。
试试这个分类器,换几种请求看它怎么分:
系统提示词(这次请求一起发送,点开查看)
{
"tier": "complex",
"reason": "要比较三个套餐的退款规则,并结合用户的订单记录计算金额。",
"max_tokens": 1000,
"needs_retrieval": true
}再试试「你好」「营业时间是几点?」「把这句话翻译成英文:明天见」,看档位和 max_tokens 怎么变。
接到代码里,分类失败或便宜模型出错时都要有退路:
from openai import APIConnectionError, APIStatusError, APITimeoutError
CHEAP_MODEL = "gpt-5.4-mini" # 占位:从 HiveGPT 模型列表里选一个便宜、快的模型
STRONG_MODEL = "gpt-5.5"
ROUTER_SYSTEM = "你是请求分类器,只判断请求难度……" # 同上面运行框里的 system
ROUTE_SCHEMA = {...} # 同上面运行框里的 schema
def route(trace_id, question):
if len(question) <= 10 and not any(w in question for w in ("代码", "比较", "计算", "方案")):
return {"tier": "simple", "max_tokens": 300, "needs_retrieval": False} # 规则:短问题
resp = traced_chat(trace_id, "route", model=CHEAP_MODEL, max_completion_tokens=300,
messages=[{"role": "system", "content": ROUTER_SYSTEM}, {"role": "user", "content": question}],
response_format={"type": "json_schema", "json_schema": ROUTE_SCHEMA})
return json.loads(resp.choices[0].message.content)
def answer(trace_id, question):
try:
r = route(trace_id, question)
except Exception: # 分类失败:按难题处理
r = {"tier": "complex", "max_tokens": 1500, "needs_retrieval": True}
model = STRONG_MODEL if r["tier"] == "complex" else CHEAP_MODEL
max_tokens = min(max(r["max_tokens"], 100), 2000) # 模型给的数字也要夹在合理范围内
messages = [{"role": "system", "content": "你是简洁的客服助手。"}, {"role": "user", "content": question}]
# needs_retrieval 为 True 时,先按 H4 检索资料再放进 messages,这里省略
try:
return traced_chat(trace_id, "answer", model=model, messages=messages, max_completion_tokens=max_tokens)
except (APIStatusError, APIConnectionError, APITimeoutError):
if model == STRONG_MODEL:
raise
return traced_chat(trace_id, "answer-fallback", model=STRONG_MODEL,
messages=messages, max_completion_tokens=max_tokens)上线后按档位看 👎 比例和费用。如果 simple 档的 👎 明显偏多,说明分得太激进,调规则或分类提示词。分类器本身也可以用 H2 的方法建一个小评估集来测。
成本控制
A8 的方法(控制上下文、限制输出、按任务选模型)之外,生产中还常用:
- 按档位设
max_tokens:分类只要几十到几百个 token,简单问答几百,长文再放开。 - 缩短上下文:历史对话做摘要(H3),检索只带相关度高的几段(H4),工具结果先截断再交回。
- 离线任务批量做:夜间汇总、批量打标签这类不需要实时的任务,把多条短文本合进一次请求,用便宜模型,控制并发慢慢跑。
- 每个用户设额度:除了 A8 的请求次数限流,再按 token 或费用设每日上限,超了就降级到便宜模型或提示明天再来。
- 按功能算账:从 trace 里按功能汇总费用,先优化开销大的功能。
安全上线:版本、灰度、回滚
提示词和模型都写进配置并标上版本号,每个 span 记下当时用的版本:
import hashlib
RELEASE = {"current": {"prompt": "support-v3", "model": "gpt-5.5"},
"canary": {"prompt": "support-v4", "model": "gpt-5.5"},
"canary_percent": 5}
def pick_release(user_id):
bucket = int(hashlib.md5(user_id.encode()).hexdigest(), 16) % 100 # 同一用户总落在同一组
return RELEASE["canary"] if bucket < RELEASE["canary_percent"] else RELEASE["current"]- 先跑评估:用 H2 的评估集跑新版本,通过率不低于旧版本才继续。
- 小比例灰度:先给 5% 的用户,看一两天的错误率、p95、费用和 👎 比例,和旧版本对比。
- 逐步放大:没问题再加到 20%、50%、100%。
- 随时回滚:有问题把
canary_percent改回 0,不用重新部署。
一次只改一样东西。同时换模型和提示词,变好变坏都说不清是谁的原因。
结业项目:一个能上线的 AI 应用
做一个真实场景的 AI 应用,例如客服助手(回答某个产品或店铺的问题)或学习助手(按教材答疑、出练习题),可以在 A8 的聊天机器人上继续做。它要包含:
- 评估集(H2):建议至少 20 条用例和一个运行脚本,README 里写上最近一次的通过率。
- 护栏(H6):至少一项,例如提示注入检查、输出里的敏感信息过滤,或有副作用操作的人工确认(H7)。
- 日志和追踪:每个请求一个 trace id,记录模型、token、耗时、费用和错误;提示词脱敏后再存。
- 一项成本优化:缓存、模型路由、缩短上下文任选一项,在 README 里写出优化前后的 token 或费用对比。
提交方式任选其一:
- 用 HiveGPT「我的网站」(网站托管)发布项目页面:介绍功能,放截图、评估结果和成本对比。它托管的是静态网页,后端和 Key 要放在你自己的服务器上。
- 提交公开的 GitHub 或 Gitee 仓库链接。仓库里不要有 Key,用
.gitignore排除.env。
小结
- 每个请求一个 trace,每次调用一条 span,记模型、token、耗时、费用、错误;提示词脱敏后再存;👎 连同 trace id 进评估集。
- 精确缓存处理和用户无关的重复问题,提示词缓存靠「固定的开头放前面」;模型路由让简单请求走便宜模型,出错回退到强模型。
- 提示词和模型都带版本,评估通过后再灰度上线,有问题改配置就能回滚。
这是 H 路线的最后一课。完成全部测验和检查点、提交结业项目后,就可以到路线页领取结业证书。