第 11 期:观测——模型网关与 Langfuse
前十期一直在本机直连一家供应商的 key,日志全靠 print。这在写教程时够用,
换到真实项目里立刻露馅:换一家供应商要改代码,key 散落在每个接入方手里,
出了问题只能翻 stdout 里那几行——而 stdout 早就被下一次运行冲掉了。这一期
把这两件事补上:一个统一的模型网关(litellm),和一套记录“每次调用发生了
什么“的观测(Langfuse)。这跟选型结果无关——不管做的是单 prompt、
预定义流程还是 agent,这两样都用得上,所以放在 Part 1 基础篇收尾,
不是哪个场景专属的知识。
敲进去
litellm——统一的模型网关
是什么:一个独立跑起来的 HTTP 服务。朝上,它对业务代码说标准 OpenAI
协议;朝下,它接 DeepSeek、OpenAI、Anthropic 等上百家供应商。这本书从
第 1 期起,common/llm.py 里的 MODEL_BASE_URL/MODEL_API_KEY/
MODEL_NAME 三个环境变量,说的就是“随时可能是网关,也可能是供应商
原生端点“——这一期把网关那一半接上,验证代码是不是真的一行不用改。
装起来:
uvx --from 'litellm[proxy]' litellm --config litellm-config.yaml --port 4477
code/litellm-config.example.yaml 声明两件事——对外的别名怎么映射到
真实模型,网关自己的认证密钥:
model_list:
- model_name: chat-default # 对外的别名,业务代码只认这个(MODEL_NAME)
litellm_params:
model: deepseek/deepseek-chat # 真实供应商/型号,只有网关知道
api_key: os.environ/DEEPSEEK_API_KEY
general_settings:
master_key: sk-local # 网关自己签发的密钥,对应 MODEL_API_KEY
api_key: os.environ/XXX 是 litellm 的写法,让它去读环境变量,供应商的
真实 key 不落进配置文件。起来之后探活:
curl http://localhost:4477/health/readiness
Langfuse——观测
是什么:记录“模型每次被调用时发生了什么“。出了问题,没有记录只能猜 是哪一次调用、传了什么参数;效果好不好,没有量化只能靠“感觉还行“。
装起来(本机已经跑着一套自托管的,见加分练习 1 补一份从零启动的
说明):拿到 LANGFUSE_PUBLIC_KEY/LANGFUSE_SECRET_KEY/LANGFUSE_HOST
三个值,填进 code/.env。
接进图里,ep11/main.py 新增两个函数:
def build_run_config(thread_id: str, user_id: str) -> dict:
config = {"configurable": {"thread_id": thread_id, "user_id": user_id}}
if settings.LANGFUSE_ENABLED:
from langfuse.langchain import CallbackHandler
config["callbacks"] = [CallbackHandler()]
config["metadata"] = {"langfuse_session_id": thread_id, "langfuse_user_id": user_id}
return config
def report_trace(config: dict) -> None:
callbacks = config.get("callbacks") or []
if not callbacks:
return
from langfuse import get_client
trace_id = callbacks[0].last_trace_id
get_client().flush()
if trace_id:
print(f"[langfuse] trace: {settings.LANGFUSE_HOST}/trace/{trace_id}")
callbacks 是 LangChain/LangGraph 每次调用都认的标准参数,塞一个
CallbackHandler() 进去,图里每个节点、每次真实的模型调用,都会自动
变成 Langfuse 里的一条记录,不用在业务代码里插一行埋点。metadata 里
langfuse_session_id/langfuse_user_id 是 Langfuse 认的专用字段名,
把这次调用的 trace 归到正确的会话和用户名下——不传这两个,Langfuse
后台会把所有对话的 trace 全堆在一起,看不出哪条是哪场对话的。
report_trace 补一步容易被漏掉的收尾:这是一个跑完就退出的 CLI 进程,
Langfuse SDK 默认攒够一批或者等一会儿才真正发送,flush() 强制立刻发,
慢一步这条记录就随着进程一起没了。两个调用点(正常提问、--resume)
跑完都调一次。
跑起来
先起网关,再跑客服 agent,两件事叠在一起验证:
cd code
uvx --from 'litellm[proxy]' litellm --config litellm-config.yaml --port 4477 &
export MODEL_BASE_URL=http://localhost:4477/v1
export MODEL_API_KEY=sk-local
export MODEL_NAME=chat-default
uv run python -m ep11.main chen c1 "订单 KL-901 想改期,但已经超过免费改期窗口了,我这边突然要住院"
你应该看到什么
网关:换端点,代码一行不改
先只验证网关本身,把第 10 期的多订单场景原样跑一遍,只换三个环境变量:
[agent] 要调 check_orders({'order_ids': ['KL-778', 'KL-901', 'KL-315'], 'aspect': '能不能改期'}) (prompt_tokens=1640)
[lookup_order] KL-778 开始(t=3461952.89)
[lookup_order] KL-901 开始(t=3461952.89)
[lookup_order] KL-315 开始(t=3461952.89)
[lookup_order] KL-315 结束(耗时 2.06s)
[lookup_order] KL-901 结束(耗时 2.24s)
[lookup_order] KL-778 结束(耗时 2.67s)
[agent] 回答:三个订单的改期情况如下:
- **KL-778**:可以改期,出行日前3天可免费改期一次,改期后不可再改。
- **KL-901**:出行日为2026-09-05,当前在出行日前3天以上,可免费改期一次。
- **KL-315**:可以改期,任意时间可免费改期,需在原出发时间前完成。
请问需要我帮您操作哪个订单的改期吗? (prompt_tokens=1847)
跟第 10 期直连 DeepSeek 的结果逐字对得上——并行扇出照常触发,三个
lookup_order 照常同时开始。common/llm.py 从第 1 期起就没写死过任何
供应商,这就是那个设计现在兑现的地方:接不接网关,图和工具一行代码
都不用碰。
Langfuse:一次调用,一条能查的记录
跑“跑起来“那条超窗改期的例子,真机输出(走网关 + Langfuse 都开着):
[agent] 要调 get_order({'order_id': 'KL-901'}) (prompt_tokens=1642)
[agent] 要调 get_current_time({}) (prompt_tokens=1642)
[tools] get_order 返回:{"order_id": "KL-901", "customer": "陈先生", ...
[tools] get_current_time 返回:Input validation error: 'timezone' is a required property
[agent] 要调 get_current_time({'timezone': 'Asia/Shanghai'}) (prompt_tokens=1789)
[agent] 要调 get_policy({'product_id': 'SKU-1001', 'topic': 'reschedule'}) (prompt_tokens=1789)
[tools] get_current_time 返回:{"timezone": "Asia/Shanghai", "datetime": "2026-09-03T17:42:25+08:00", ...}
[tools] get_policy 返回:东京迪士尼一日票 的 reschedule 政策:出行日前 3 天可免费改期一次,改期后不可再改;出行日前 3 天内不支持改期。
[agent] 要调 load_skill({'name': 'reschedule-dispute'}) (prompt_tokens=2001)
[tools] load_skill 返回:# 超窗改期例外处理 ...
[agent] 回答:陈先生,了解到您因突发住院需要改期,非常理解。
您的订单 KL-901(东京迪士尼一日票,出行日 9月5日)已超过免费改期窗口1天。考虑到您住院的特殊情况,这次为您做一次性特殊处理,允许改期。
请注意这是特殊破例,不代表以后超窗也能改。请问您希望改到哪一天?我帮您操作。 (prompt_tokens=2420)
[langfuse] trace: http://localhost:3000/trace/54c41d98acc2da6e27a480d6df97bd29
模型第一次调 get_current_time 时参数传空了({}),工具照第 7 期
inputSchema 的规矩原样报错,模型下一轮自己带上 timezone 重试——
这条纠错过程完整地进了历史,也完整地进了 Langfuse 的记录。
打开这条 trace 链接前,先用 API 核对它是不是真落进了数据库(GET /api/public/v2/observations?traceId=...,本机真实返回,节选):
CHAIN route trace=54c41d98... latency=0.001s
CHAIN route_after_tools trace=54c41d98... latency=0.001s
TOOL get_order trace=54c41d98... latency=0.006s
TOOL get_current_time trace=54c41d98... latency=0.003s
GENERATION ChatOpenAI trace=54c41d98... latency=2.1s
AGENT agent trace=54c41d98... latency=2.1s
TOOL get_policy trace=54c41d98... latency=0.004s
TOOL load_skill trace=54c41d98... latency=0.002s
节点名和第 3 期起就有的图结构逐一对上:route、agent、
route_after_tools、tools 里每个真正被调用的工具,各自变成一条独立
记录,不用改一行业务代码去埋点。查这条 trace 的根记录,userId
是 "chen"、sessionId 是 "c1"——metadata 里那两个 langfuse_
字段真的把这次调用挂到了正确的客人和会话名下。
发生了什么
这两样跟选型结果无关,所以放在这里收尾。 从第 1 期的三层判断 到第 10 期的并行子图,前面十期一直在回答“这个需求该长成什么样“;网关 和观测答的是另一个问题——不管长成什么样,换供应商要不要重新发版、 出问题能不能查,这两条线跟具体做的是单 prompt 还是 agent 没有关系。 放在 Part 1 收尾,是想在进入部署篇之前把这份共用地基铺好。
代码“一行不改“,靠的是第 1 期就定下的三个环境变量。 common/llm.py
从来没有在任何一期里写死过供应商——ChatOpenAI(base_url=..., api_key=..., model=...) 三个参数从第一天起就是环境变量。网关能够无缝接入,不是这一期
新加的能力,是十期以来一直遵守的一条纪律在这一刻兑现。
Langfuse 接进 LangGraph,靠的是 LangChain 的标准回调协议,不是专门
适配。 callbacks=[CallbackHandler()] 这个参数,跟 config 里的
configurable/thread_id 是同一层机制——LangGraph 的每个节点、每次
模型调用,本来就会把自己的开始和结束广播给所有注册的 callback,
CallbackHandler 只是把这些广播转存成 Langfuse 的记录格式。这跟第 7 期
“MCP 的工具声明和 function calling 的工具声明是同一回事“是同一类
巧合:不是刻意为 LangGraph 做了适配,是两边本来就用同一套协议描述
“发生了什么”。
session/user 的挂载点在 trace 的根节点,不是每一条子记录上。
一次调用产生的一整棵 span 树只有一个 userId/sessionId,挂在树根;
查任何一条子记录(比如某次 GENERATION)都看不到这两个字段,这不是
没生效,是设计上就不需要每一层重复——顺着 traceId 找到根节点才是
查这两个字段的地方。
没查到 token 数和费用,这是本机这套自托管环境的真实缺口,如实记
在这里。 通过 API 核对这次调用时,GENERATION 类型的记录里
usage/totalPrice/modelId 全是空——这台机器上的 Langfuse 服务器
跑在文档标注为“v4 events_only“的过渡模式下,/api/public/v2/observations
这个接口本身也在部分字段上打了弃用/未完成的标记。结构、时间线、
session/user 这几件事验证是真的成立的;费用统计这条链路在这台机器上
没打通,不确定是版本过渡期的限制还是配置差了一步,没有查清楚就不
硬说“能用“。这本书从第 3 期起一直用 msg.usage_metadata 拿真实
token 数,这条路径不依赖 Langfuse,仍然是这本书里唯一确认可靠的
账本。
常见问题
每期都要起网关吗? 不用,网关是可选项。没有网关时三个环境变量 直接填供应商的地址和 key,前十期一直这么跑;网关的价值在换供应商、 多团队共用一个端点的场景里才体现出来,教学环境用不上也没关系。
Langfuse 会拖慢正常对话吗? CallbackHandler 是异步上报,不在
await llm.invoke() 的关键路径上等结果;report_trace 里的
flush() 才是唯一会等的地方,而且只在进程即将退出前调一次,不影响
中途每一轮的响应速度。
没配 Langfuse 会报错吗? 不会。settings.LANGFUSE_ENABLED 由
LANGFUSE_PUBLIC_KEY/LANGFUSE_SECRET_KEY 是否都填了决定,没填就是
False,build_run_config 直接跳过整段 Langfuse 相关代码,config
里连 callbacks 键都不会出现——这也是为什么前十期能在完全没有 Langfuse
的情况下正常跑到现在。
为什么用 API 核对而不是直接截图 Langfuse 后台? 这本书的规矩是 “真机跑出来的才写”——API 返回的 JSON 比一张网页截图更适合当证据: 字段名、延迟数字、trace id 都能核对,读者也能拿自己的 key 跑同一条 命令重现,截图做不到这一点。
加分练习
- 从零启动一套自托管 Langfuse(
docker-compose up),走一遍 headless 初始化(LANGFUSE_INIT_*那组环境变量),跟本机复用现成 实例这条路对比一下哪里更省事。 - 把
litellm-config.example.yaml里的后端从 DeepSeek 换成另一家 供应商,重启网关,客户端代码一个字不改,验证“换模型不用发版“这 条到底成不成立。 - 给
get_current_time那次参数校验失败的重试单独查一次 trace, 看 Langfuse 记没记下“第一次失败、第二次重试成功“这两次调用的 先后顺序和各自的错误信息。 - 查一下这台机器上“v4 events_only“模式具体缺了哪些字段——官方 文档或者升级到非 events_only 模式,看 token/费用这条链路能不能 打通,回答“发生了什么“里留的那个疑问。