Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

第 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 里的一条记录,不用在业务代码里插一行埋点。metadatalangfuse_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 期起就有的图结构逐一对上:routeagentroute_after_toolstools 里每个真正被调用的工具,各自变成一条独立 记录,不用改一行业务代码去埋点。查这条 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_ENABLEDLANGFUSE_PUBLIC_KEY/LANGFUSE_SECRET_KEY 是否都填了决定,没填就是 Falsebuild_run_config 直接跳过整段 Langfuse 相关代码,config 里连 callbacks 键都不会出现——这也是为什么前十期能在完全没有 Langfuse 的情况下正常跑到现在。

为什么用 API 核对而不是直接截图 Langfuse 后台? 这本书的规矩是 “真机跑出来的才写”——API 返回的 JSON 比一张网页截图更适合当证据: 字段名、延迟数字、trace id 都能核对,读者也能拿自己的 key 跑同一条 命令重现,截图做不到这一点。

加分练习

  1. 从零启动一套自托管 Langfuse(docker-compose up),走一遍 headless 初始化(LANGFUSE_INIT_* 那组环境变量),跟本机复用现成 实例这条路对比一下哪里更省事。
  2. litellm-config.example.yaml 里的后端从 DeepSeek 换成另一家 供应商,重启网关,客户端代码一个字不改,验证“换模型不用发版“这 条到底成不成立。
  3. get_current_time 那次参数校验失败的重试单独查一次 trace, 看 Langfuse 记没记下“第一次失败、第二次重试成功“这两次调用的 先后顺序和各自的错误信息。
  4. 查一下这台机器上“v4 events_only“模式具体缺了哪些字段——官方 文档或者升级到非 events_only 模式,看 token/费用这条链路能不能 打通,回答“发生了什么“里留的那个疑问。