今天要解决的问题
前四天的零件都齐了:知识库能检索,工具能查账单、能建退款申请,数据在库里。今天把它们串成一条能处理真实工单的流水线。
今天结束时你应该做到:
- 项目里有一个统一的
LLMClient:记账(花了多少钱)、结构化输出(校验失败自动重试)、流式输出(边生成边推给前端)。 - 一张 LangGraph 图,9 个节点,4 个条件分支,每个节点做什么、为什么这么分,能讲清楚。
- 退款申请创建后,图停在审批节点等人;人点了通过或驳回,图从停下的地方接着跑。
- 不连数据库、不调大模型,用假 LLM 跑通 6 个单测:退款审批、转人工时作废待审批申请、纯政策问题、转人工、质检两次不过、违规承诺被拦。
- 终端里跑 10 张演示工单,看着每一步在干什么。
预计用时 5–7 小时,是 12 天里最核心的一天。前置:Day 9(手写 Agent 循环)、Day 13(五种 workflow 模式)、Day 24–25(LangGraph 基础和人工审批)、Day 45–46。
LLMClient:从 W1 搬过来,加三样东西
W1 的 Day 4 你封装过一个 LLMClient。现在搬进项目,补上产品级需要的三样东西:
# supportpilot/src/supportpilot/llm.py
"""W1 的 LLMClient 搬过来,加了三样东西:成本账本、结构化输出重试、流式回调。"""
import json
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any
from openai import OpenAI
from pydantic import BaseModel, ValidationError
from .config import Settings, get_settings
@dataclass
class Usage:
prompt_tokens: int = 0
completion_tokens: int = 0
calls: int = 0
def add(self, other: "Usage") -> None:
self.prompt_tokens += other.prompt_tokens
self.completion_tokens += other.completion_tokens
self.calls += other.calls
def cost_cny(self, s: Settings) -> float:
return (self.prompt_tokens * s.price_in_per_m + self.completion_tokens * s.price_out_per_m) / 1_000_000
@dataclass
class ChatResult:
content: str
tool_calls: list[dict] = field(default_factory=list) # [{"id","name","arguments"(str)}]
usage: Usage = field(default_factory=Usage)
raw_message: dict = field(default_factory=dict) # 原样回传给下一轮 messages 用
class LLMClient:
def __init__(self, settings: Settings | None = None, model: str | None = None):
self.s = settings or get_settings()
self.model = model or self.s.llm_model
# max_retries 交给 SDK:429 和 5xx 会自动指数退避重试
self.client = OpenAI(base_url=self.s.llm_base_url, api_key=self.s.llm_api_key, max_retries=3, timeout=60)
self.ledger = Usage() # 这个实例累计花了多少 token
def _usage(self, u: Any) -> Usage:
got = Usage(prompt_tokens=getattr(u, "prompt_tokens", 0) or 0,
completion_tokens=getattr(u, "completion_tokens", 0) or 0, calls=1)
self.ledger.add(got)
return got
def chat(self, messages: list[dict], *, tools: list[dict] | None = None, temperature: float = 0.2,
json_mode: bool = False, model: str | None = None) -> ChatResult:
kwargs: dict[str, Any] = {"model": model or self.model, "messages": messages, "temperature": temperature}
if tools:
kwargs["tools"] = tools
if json_mode:
kwargs["response_format"] = {"type": "json_object"}
if self.s.llm_extra_body: # 比如关掉思考:{"thinking": {"type": "disabled"}}
kwargs["extra_body"] = self.s.llm_extra_body
resp = self.client.chat.completions.create(**kwargs)
msg = resp.choices[0].message
calls = [{"id": c.id, "name": c.function.name, "arguments": c.function.arguments or "{}"}
for c in (msg.tool_calls or [])]
raw: dict[str, Any] = {"role": "assistant", "content": msg.content or ""}
if calls:
raw["tool_calls"] = [{"id": c["id"], "type": "function",
"function": {"name": c["name"], "arguments": c["arguments"]}} for c in calls]
return ChatResult(content=msg.content or "", tool_calls=calls, usage=self._usage(resp.usage), raw_message=raw)
def stream(self, messages: list[dict], *, on_token: Callable[[str], None] | None = None,
temperature: float = 0.3, model: str | None = None) -> ChatResult:
"""流式生成。每来一段文字就调 on_token,最后返回拼好的完整结果。"""
resp = self.client.chat.completions.create(
model=model or self.model, messages=messages, temperature=temperature,
stream=True, stream_options={"include_usage": True},
extra_body=self.s.llm_extra_body or None,
)
parts: list[str] = []
last_usage = None
for chunk in resp:
if chunk.usage: # 有的厂商只在最后一块带 usage,有的每块都带累计值,所以只记最后一次
last_usage = chunk.usage
if chunk.choices and chunk.choices[0].delta.content:
piece = chunk.choices[0].delta.content
parts.append(piece)
if on_token:
on_token(piece)
text = "".join(parts)
usage = self._usage(last_usage) # 循环结束后只入账一次
return ChatResult(content=text, usage=usage, raw_message={"role": "assistant", "content": text})
def structured[T: BaseModel](self, messages: list[dict], schema: type[T], *, retries: int = 2,
model: str | None = None) -> T:
"""JSON mode + pydantic 校验,校验失败把错误贴回去让模型改,最多重试 retries 次。"""
hint = ("只输出一个 JSON 对象,不要任何解释。JSON Schema 如下:\n"
+ json.dumps(schema.model_json_schema(), ensure_ascii=False))
msgs = [*messages, {"role": "system", "content": hint}]
last_err = ""
for _ in range(retries + 1):
res = self.chat(msgs, json_mode=True, temperature=0, model=model)
try:
return schema.model_validate_json(res.content)
except ValidationError as e:
last_err = str(e)[:800]
msgs = [*msgs, res.raw_message,
{"role": "user", "content": f"上面的 JSON 没通过校验,错误是:\n{last_err}\n请改正后只输出 JSON。"}]
raise ValueError(f"结构化输出重试 {retries} 次仍失败:{last_err}")
"""W1 的 LLMClient 搬过来模块说明直接写清楚比 W1 版本多了什么:账本、结构化重试、流式回调。以后有人打开这个文件,第一行就知道它的职责。from collections.abc import Callable给on_token标类型用。Callable[[str], None]读作「接收一个 str、不返回值的函数」。from dataclasses import dataclass, fielddataclass自动生成__init__;field(default_factory=...)专门给列表、字典这类可变默认值用,每个实例各拿一份新的。from .config import Settings, get_settings点开头是包内相对导入,指同一个supportpilot包里的config.py。所以这个文件只能以supportpilot.llm的身份被导入,直接python llm.py会报attempted relative import with no known parent package。class Usage:三个计数器:输入 token、输出 token、调用次数。既用来表示「一次调用花了多少」,也用来当「累计账本」。def add(self, other: "Usage") -> None:类型写成字符串"Usage"是前向引用:写这一行时Usage类还没定义完。实测账本的变化:先chat一次是Usage(12, 28, 1),再structured一次变成Usage(623, 85, 2),再流式一次变成Usage(635, 97, 3)。def cost_cny(self, s: Settings) -> float:单价是「元 / 百万 token」,所以除以 1_000_000(数字里的下划线只是分隔符,等于 1000000)。代入上面最后的账本:635 × 2 / 1e6 + 97 × 8 / 1e6 = 0.00127 + 0.000776 = 0.002046 元,和实测打印的0.002046一致。class ChatResult:一次调用的结果,把 SDK 的复杂对象压成四个字段,节点代码只跟它打交道。tool_calls: list[dict] = field(default_factory=list)不能写成= []:dataclass 会直接报ValueError: mutable default <class 'list'> for field tool_calls is not allowed: use default_factory。注释里写明了每个元素的三个键,arguments是 JSON 字符串,还没解析。raw_message: dict = field(default_factory=dict)按 OpenAI 协议拼好的 assistant 消息,act 节点下一轮直接msgs.append(res.raw_message)。self.s = settings or get_settings()没传就从.env读。or在左边是 None 时取右边。self.model = model or self.s.llm_model实测是deepseek-flash。留一个model参数是给 Day 52 的小模型用的。self.client = OpenAI(max_retries=3交给 SDK 做 429/5xx 的指数退避;timeout=60防止一个请求卡死整张工单。self.ledger = Usage()每个LLMClient实例一本账,初始 0/0/0。Deps.spent()读的就是它。def _usage(self, u: Any) -> Usage:把 SDK 的 usage 对象换成自己的Usage,顺手记进账本。所有调用都经过这里,账就不会漏。got = Usage(prompt_tokens=getattr(u, "prompt_tokens", 0) or 0,getattr(对象, 名字, 默认值):u是 None(流式没拿到 usage)时不会抛 AttributeError,而是拿到 0;后面的or 0再把「属性存在但值是 None」兜成 0。def chat(self, messages: list[dict], *, tools参数列表里单独一个*,表示后面的参数只能用关键字传:chat(msgs, tools=t)可以,chat(msgs, t)会报错。参数多的函数这样写,调用处不会传错位置。kwargs: dict[str, Any] = {"model"先放三个必有的参数,下面按需往里加。if tools:没有工具时干脆不带tools这个键,请求里少一个空字段。kwargs["response_format"] = {"type": "json_object"}开 JSON mode。test_llm 实测第一次请求带上了它,temperature是 0。if self.s.llm_extra_body:配了就透传,没配(空字典)就不带这个键。实测.env里是{"thinking": {"type": "disabled"}},关掉 deepseek-flash 默认的思考。kwargs["extra_body"] = self.s.llm_extra_bodyextra_body是 openai SDK 留的口子:SDK 不认识的厂商专有参数放进去,会原样合并进请求体。resp = self.client.chat.completions.create(**kwargs)**kwargs把字典展开成关键字参数,等于写create(model=..., messages=..., ...)。calls = [{"id": c.id, "name": c.function.name把 SDK 的工具调用对象压平成普通字典。msg.tool_calls or []防止没有工具调用时是 None。实测「能退吗」那张工单,act 第一轮一次返回了两个调用:list_subscriptions {"customer_id": "C0002"}和list_invoices {"customer_id": "C0002", "limit": 10}。raw: dict[str, Any] = {"role": "assistant", "content": msg.content or ""}实测问「用一句话说 CloudLedger 是什么」,raw_message 是{'role': 'assistant', 'content': 'CloudLedger 是一个基于云端的财务记账与账本管理平台,用于记录、分类和追踪个人或企业的收支流水。'}。模型没见过我们虚构的 CloudLedger,这句是它看名字猜的(开着思考时它猜成了「分布式账本平台」),所以产品知识一定要靠检索喂给它。raw["tool_calls"] = [{"id": c["id"], "type": "function",有工具调用时按协议格式拼回去。下一轮的 tool 消息靠id和它对上,对不上 API 会报 400。return ChatResult(content=msg.content or ""实测上面那次问答 usage 是 12 输入、28 输出。关掉思考之前,同一个问题是 38 输入、256 输出:思考的 token 也算在 completion 里计费,一句话的回答多付了近 9 倍。def stream(self, messages流式版本,draft 节点用它给前端推打字机效果。stream=True, stream_options={"include_usage": True},不加include_usage,流式响应里根本没有 usage,账本就漏记了。extra_body=self.s.llm_extra_body or None,流式也要关思考。or None:没配时传 None,等于不带这个参数。parts: list[str] = []收集每一段文字,最后拼起来。for chunk in resp:实测流式问「写一句 20 字的客服问候」:关掉思考后一共 14 块,12 块带正文,剩下的是收尾块和 usage 块。不传 extra_body 直接调接口对比了一次:1010 块,只有十几块是正文,其余都是思考内容,delta.content为空,会被下面的 if 跳过。if chunk.usage:实测 DeepSeek 只在最后 1 块带 usage:关思考时是 12 输入 / 12 输出,不关时是 38 输入 / 1009 输出。别的厂商可能每块都带累计值,代码按「只记最后一次」写,两种行为都不会算错。if chunk.choices and chunk.choices[0].delta.content:最后那块 usage 块的choices是空列表,所以先判断chunk.choices非空再取[0],否则会 IndexError。on_token(piece)实测前 5 段依次是'您好'、','、'很高兴'、'为您'、'服务'。draft 节点传进来的 on_token 会把每段推到 custom 流。text = "".join(parts)12 段拼成您好,很高兴为您服务,请问有什么可以帮您?。usage = self._usage(last_usage)循环结束后只入账一次。这次是 12 / 12。def structured[T: BaseModel](Python 3.12 的泛型写法:传进来Triage类,返回值就是Triage实例,编辑器能提示.category这些字段。hint = ("只输出一个 JSON 对象把 schema 转成 JSON 塞进提示词。实测 Triage 的 schema JSON 有 844 个字符,ensure_ascii=False让中文 description 原样出现,不变成工这种转义。msgs = [*messages, {"role": "system"[*a, x]是解包拼新列表,不改调用方传进来的 messages。测试实测第一次请求带 2 条消息:原来的 user 加上这条 system 提示。for _ in range(retries + 1):retries=2 就是最多 3 次。_表示循环变量用不上。return schema.model_validate_json(res.content)解析加校验一步完成。实测分诊「上周买的专业版年付,发现用不上,请帮我退款」,一次就过:{'category': 'refund', 'needs_account_data': True, 'needs_human': False, 'urgency': 'normal', 'summary': '客户要求退上周购买的专业版年付费用', 'search_query': '专业版 年付 退款'},这次调用 57 个输出 token。last_err = str(e)[:800]错误信息截到 800 字,太长会白占 token。测试实测的错误是1 validation error for Answer / confidence / Field required [type=missing, input_value={'category': 'refund'}, ...]。msgs = [*msgs, res.raw_message,把模型这次的输出和错误一起贴回去,让它对着改。测试实测第二次请求有 4 条消息:user、system 提示、模型上次的 JSON、「没通过校验」这条。raise ValueError(f"结构化输出重试3 次都不过才放弃。测试里连续 3 次返回not json,实测报错结构化输出重试 2 次仍失败:1 validation error for Answer Invalid JSON: expected ident at line 1 column 2 ...。
成本账本 ledger。 每次调用都把 usage 累加到实例的账本上,cost_cny() 按配置的单价换算成人民币。每个节点在开始前记一下账本读数,结束后算差值,就知道这一步花了多少。整张工单的成本在状态里累加,评测报告里就能出现”平均每张工单 ¥0.03”这种数字。
结构化输出 structured()。 用 JSON mode 加 pydantic 校验。JSON mode 只保证输出是合法 JSON,不保证字段对,所以还要校验。校验失败时把错误信息原样贴回去让模型改,最多重试两次。DeepSeek 的文档特别提醒:开 JSON mode 必须在提示词里说”输出 JSON”,否则模型可能一直输出空白直到 token 用完,所以 hint 里明确写了”只输出一个 JSON 对象”并附上 schema。
def structured[T: BaseModel](...) 是 Python 3.12 的泛型写法,意思是”传进来什么 schema 类型,就返回什么类型的实例”,编辑器能正确提示返回值的字段。
流式输出 stream()。 stream_options={"include_usage": True} 让流式响应也带上 token 用量。DeepSeek 我实测过,只有最后一块带 usage,前面的块都是空的;但不是所有厂商都这样,有的每一块都带一个累计值,那种情况下每块都入账,成本会被算成好几十倍。所以循环里只记住最后一次的 usage,循环结束后入账一次,两种行为都不会算错。
关掉思考 extra_body。 全课的惯例是用 .env 里的 LLM_EXTRA_BODY 关掉 deepseek-flash 默认开着的思考(Day 4 的 llm.py 就是这么做的)。Day 44 的 Settings 里还没有这一项,今天在 config.py 的 Settings 类里补一行:
# Day 47:透传给大模型接口的额外参数(JSON)。deepseek-flash 默认开思考,填 {"thinking": {"type": "disabled"}} 关掉
llm_extra_body: dict = {}
.env 里写 LLM_EXTRA_BODY={"thinking": {"type": "disabled"}},pydantic-settings 会把这段 JSON 解析成字典。chat() 和 stream() 都把它原样透传给接口。不关的代价很实在:我实测问一句「CloudLedger 是什么」,开着思考输出 256 个 token,关掉后 28 个;10 张演示工单全跑一遍,开着思考约 0.26 元,关掉后约 0.17 元。客服回复不需要模型先长篇思考,这钱省得心安理得。
max_retries=3 交给 openai SDK,它会对 429 和 5xx 自动做指数退避重试,这是 Day 12 你手写过的逻辑,SDK 已经帮你做好了。
先用假的 OpenAI 客户端测一下这三样东西:
# supportpilot/tests/test_llm.py
"""LLMClient 的单测:用假的 OpenAI 客户端,验证成本记账和结构化输出的重试。"""
from types import SimpleNamespace as NS
import pytest
from pydantic import BaseModel
from supportpilot.config import Settings
from supportpilot.llm import LLMClient
class Answer(BaseModel):
category: str
confidence: float
def fake_client(replies: list[str]):
calls = []
def create(**kw):
calls.append(kw)
text = replies.pop(0)
msg = NS(content=text, tool_calls=None)
return NS(choices=[NS(message=msg)], usage=NS(prompt_tokens=1000, completion_tokens=100))
return NS(chat=NS(completions=NS(create=create))), calls
def make(replies):
llm = LLMClient(Settings(llm_api_key="test", price_in_per_m=2.0, price_out_per_m=8.0))
llm.client, calls = fake_client(replies)
return llm, calls
def test_structured_retries_with_validation_error():
llm, calls = make(['{"category": "refund"}', '{"category": "refund", "confidence": 0.9}'])
out = llm.structured([{"role": "user", "content": "x"}], Answer)
assert out.category == "refund" and len(calls) == 2
assert "没通过校验" in calls[1]["messages"][-1]["content"] # 第二次带上了错误信息
assert calls[0]["response_format"] == {"type": "json_object"}
def test_structured_gives_up():
llm, _ = make(["not json"] * 3)
with pytest.raises(ValueError, match="重试 2 次"):
llm.structured([{"role": "user", "content": "x"}], Answer)
def test_ledger_cost():
llm, _ = make(['{"category": "a", "confidence": 1}'])
llm.structured([{"role": "user", "content": "x"}], Answer)
# 1000 输入 × 2 元/百万 + 100 输出 × 8 元/百万 = 0.002 + 0.0008
assert llm.ledger.cost_cny(llm.s) == pytest.approx(0.0028)
from types import SimpleNamespace as NSSimpleNamespace 能随手造一个带属性的对象:NS(content="x").content就是"x"。用它冒充 SDK 返回的响应对象,不用真联网。from supportpilot.config import Settings测试里直接构造 Settings 传价格,不依赖.env。class Answer(BaseModel):测试专用的小 schema,两个必填字段,方便造「缺字段」的情况。def fake_client(replies: list[str]):造一个假 OpenAI 客户端:每调一次create,按顺序吐出一条预设文字。calls = []记下每次调用传了什么参数,测试拿来断言。def create(**kw):接收任意关键字参数,和真 SDK 的调用方式对上。text = replies.pop(0)从头取一条,取一条少一条。return NS(choices=[NS(message=msg)], usage=NS(prompt_tokens=1000拼出resp.choices[0].message.content和resp.usage.prompt_tokens这两条访问路径,LLMClient 只用到这些。每次固定 1000 输入、100 输出,方便心算。return NS(chat=NS(completions=NS(create=create))), calls一层套一层,对应client.chat.completions.create。llm = LLMClient(Settings(llm_api_key="test"真的构造一个 LLMClient(里面会建一个真 OpenAI 对象,但不发请求),单价设成 2 和 8。llm.client, calls = fake_client(replies)把里面的真客户端换成假的。节点和 LLMClient 都不用为测试改一行代码。llm, calls = make(['{"category": "refund"}'第一条故意缺confidence,第二条完整。out = llm.structured([{"role": "user", "content": "x"}], Answer)实测 out 是category='refund' confidence=0.9,一共调用 2 次,两次请求分别带 2 条和 4 条消息。assert "没通过校验" in calls[1]["messages"][-1]["content"]第二次请求的最后一条消息,就是贴回去的校验错误。assert calls[0]["response_format"] == {"type": "json_object"}确认结构化输出走的是 JSON mode。llm, _ = make(["not json"] * 3)[x] * 3得到 3 个相同元素的列表,刚好够 3 次尝试用完。with pytest.raises(ValueError, match="重试 2 次"):断言这个 with 块里一定抛 ValueError,而且错误信息能被正则重试 2 次搜到。assert llm.ledger.cost_cny(llm.s) == pytest.approx(0.0028)浮点数不能直接==,approx允许极小误差。1000 × 2 / 1e6 + 100 × 8 / 1e6 = 0.002 + 0.0008 = 0.0028。顺带实测:上面重试那个测试调了 2 次,账本是 2000 / 200,0.0056 元。
状态:工作流里流动的是什么
# supportpilot/src/supportpilot/agent/state.py
import operator
from typing import Annotated, Literal, TypedDict
from pydantic import BaseModel, Field
Category = Literal["billing", "refund", "invoice", "account", "technical", "plan_change", "api", "other"]
class Ticket(TypedDict):
id: str
customer_email: str
subject: str
body: str
class SupportState(TypedDict, total=False):
ticket: Ticket
customer: dict | None
triage: dict
hits: list[dict]
tool_log: Annotated[list[dict], operator.add] # 每次工具调用追加一条,不覆盖
findings: str
refund_request: dict | None
draft: str
review: dict
revisions: int
approval: dict | None
followup: str
outcome: str # resolved / waiting_approval / escalated
handoff_reason: str
reply: str # 最终发给客户的文本(引用已渲染成链接)
cost_cny: Annotated[float, operator.add]
class Triage(BaseModel):
category: Category = Field(description="工单分类,定义见系统提示")
needs_account_data: bool = Field(description="是否必须查这个客户的订阅/账单/历史才能回答")
needs_human: bool = Field(description="是否必须直接转人工:投诉升级、法律威胁、安全事件、明确要求人工")
urgency: Literal["low", "normal", "high"]
summary: str = Field(description="一句话复述客户诉求,不超过 40 字")
search_query: str = Field(description="用来检索帮助中心的查询:保留报错码、产品名词,去掉寒暄和情绪")
class Review(BaseModel):
passed: bool
problems: list[str] = Field(default_factory=list, description="不通过时列出具体问题,每条一句话")
import operator下面用operator.add,它就是+的函数版:operator.add([1], [2])得到[1, 2],operator.add(0.1, 0.2)得到 0.3 左右。Category = Literal["billing"类型别名,8 个合法分类。模型输出"refunds"这种不在列表里的值,pydantic 校验失败,触发重试。class Ticket(TypedDict):工单的四个字段。实测第 3 张演示工单进图时是{"id": "T000153", "customer_email": "lina@example.com", "subject": "想退款", "body": "上周买的专业版年付,发现用不上,请帮我退款。"}。class SupportState(TypedDict, total=False):total=False表示每个键都可以不存在。图刚开始只有ticket,其余字段靠节点一步步填,所以节点里读可选字段要用state.get(...)。customer: dict | None实测李娜是{"id": "C0002", "name": "李娜", "email": "lina@example.com", "company": "娜美贸易", "region": "上海", "is_vip": false, "created_at": "2025-06-01"};nobody@example.com查不到,是 None。triage: dict存的是Triage.model_dump()之后的普通字典。hits: list[dict]实测李娜退款单第一条是{"chunk_id": 127, "doc_id": "KB-refund-003", "title": "年付超过 14 天怎么退", "heading": "计算方法", "score": 0.0323, "via": ["vector", "bm25"], ...}。tool_log: Annotated[list[dict], operator.add]给字段挂一个 reducer:节点返回的列表会和旧值相加(拼接),不会覆盖。实测李娜退款那单 act 返回 4 条记录,最后状态里 tool_log 长度是 4。findings: stract 节点写的调查结论,draft 和 check 都要读。refund_request: dict | Noneact 里create_refund_request成功时的返回值,路由靠它判断要不要去审批。revisions: int质检没过的次数,超过上限转人工。outcome: str注释里列了三种取值。实测 10 张演示工单里 7 张 resolved、3 张 escalated。cost_cny: Annotated[float, operator.add]每个节点只返回自己这一步花的钱,LangGraph 自动累加。实测李娜那单:classify 0.00168 + act 0.0188 + draft 0.00216 + check 0.00176,合计 0.0244 元。class Triage(BaseModel):分诊的结构化输出,Field(description=...)会进 JSON schema,等于给模型看的字段说明书。needs_account_data: bool = Field(实测「能退吗,先问问」也判成了 true,因为要算这个客户自己能退多少。urgency: Literal["low", "normal", "high"]没写 description,三个值的意思够直白。实测「想退款」判 normal,「能退吗,先问问」判 low。search_query: str = Field(实测「想退款」那单生成专业版 年付 退款,「能退吗」那单是年付订阅 退款 退款金额 退款政策。寒暄和情绪词都去掉了。class Review(BaseModel):质检的结构化输出。problems: list[str] = Field(default_factory=listpydantic 里可变默认值同样用default_factory。实测第 6 张(重复扣款)第一次质检退回了两条:「24 小时内自动退回」和调查结论不符;没说明申请还在待审批。
LangGraph 的状态是一个字典,每个节点读它、返回一个”要更新哪些字段”的小字典。大部分字段是覆盖:节点返回 {"draft": "..."},状态里的 draft 就被替换。有两个字段用了 Annotated[..., operator.add],意思是累加:
tool_log:act 节点每次返回的工具调用记录追加到列表末尾。cost_cny:每个节点返回自己花的钱,累加成整张工单的总成本。
为什么 hits 存的是字典列表而不是 Hit 对象?因为状态会被 checkpointer 序列化存进数据库(Day 48),纯字典和列表最稳妥。节点里用的时候 Hit(**h) 还原回来。
Triage 和 Review 是两个结构化输出的 schema。Triage 里我特意加了 search_query 字段:让分诊模型顺手把客户的话改写成适合检索的查询,去掉”你好""麻烦了""急急急”这些对检索没用的词。一次调用干两件事,省一次 API。
Category 用 Literal 限定了 8 个取值,模型输出其他值时 pydantic 校验会失败,触发重试。
提示词:集中放,带版本号
# supportpilot/src/supportpilot/agent/prompts.py
"""所有提示词集中放这里,带版本号。评测报告里会记录 PROMPT_VERSION,改提示词就改版本号。"""
PROMPT_VERSION = "v1"
CLASSIFY = """你是 CloudLedger(云簿,在线记账 SaaS)的工单分诊员。读客户工单,输出分类结果。
分类定义:
- billing:扣费、扣款失败、重复扣款、支付方式、账单查询(不涉及退钱的诉求)
- refund:客户要求退钱,或者问能不能退、退多少
- invoice:开发票、发票错误、重开发票
- account:登录、验证码、密码、账号锁定、绑定手机邮箱、成员权限、注销
- technical:导入导出、银行流水同步、结账、报表、App 同步等产品使用问题和报错
- plan_change:升级、降级、换年付月付、取消订阅、试用
- api:开放 API、API Key、限流、Webhook
- other:以上都不是
needs_account_data:回答必须依赖这个客户自己的订阅、账单或历史记录时为 true(比如"我被扣了两次""我能退多少");
纯政策或操作问题(比如"专票怎么开")为 false。
needs_human:客户威胁投诉到监管/媒体/律师、涉及账号被盗等安全事件、或者明确要求人工客服时为 true。"""
ACT = """你是 CloudLedger 客服后台的调查员。你的任务是用工具查清回答这张工单需要的客户数据,不直接回复客户。
规则:
1. 客户 ID 已经给你了,不需要再查 get_customer。
2. 涉及退款:先用 list_invoices 找到相关账单,再对它调用 check_refund_eligibility。退款金额只以工具返回为准,不要自己算。
3. 只有当客户明确要求退款、并且 check_refund_eligibility 返回 eligible=true 时,才调用 create_refund_request。
客户只是询问"能不能退"时不要创建申请。
4. 只查和这张工单有关的数据,查到够用就停。
5. 结束时不再调用工具,输出「调查结论」:用几条短句列出查到的关键事实(账单号、金额、状态、规则结论),不要写给客户的话。"""
DRAFT = """你是 CloudLedger 的客服,给客户写工单回复。
写作要求:
- 只使用【帮助中心资料】和【调查结论】里的事实。资料里没有的,不要猜,直接说会转给同事跟进。
- 用到帮助中心资料的句子,在句末标注来源编号,格式严格为 [KB-xxx-000],只能用资料里出现过的编号。
- 如果有待审批的退款申请:只能说"已提交退款申请,审核通过后会原路退回",不能说已经退款或承诺到账时间以外的东西。
- 如果客户不符合退款条件:说明依据的规则,给出可行的替代办法(比如关闭自动续费)。
- 如果没查到客户账户:请客户提供注册邮箱。
- 语气礼貌、直接,先回答问题再补充说明。不超过 250 字。称呼用"您好"开头,不要落款。"""
CHECK = """你是客服质检员。检查这封回复草稿能不能发给客户。
逐条核对:
1. 草稿里每一个具体事实(金额、天数、规则、操作路径)是否都能在【帮助中心资料】或【调查结论】里找到依据。
2. 是否答非所问,或者漏掉了客户的主要诉求。
3. 是否承诺了没有依据的事(比如"已经为您退款""马上到账")。
全部没问题 passed=true。有任何一条问题 passed=false,并在 problems 里逐条写清楚哪句话有什么问题。"""
PROMPT_VERSION = "v1"评测报告里记这个版本号,分数才能对应到具体的提示词。CLASSIFY = """你是 CloudLedger分诊提示词,实测 475 个字符。分诊结果的例子见 state.py 的注释。needs_account_data:回答必须依赖正反例各给一个,边界就清楚了。needs_human:客户威胁投诉到监管实测第 10 张「再不处理我就去 12315 投诉」被分成 billing,但 needs_human 是 true,直接转人工,整单只花了 0.0017 元。ACT = """你是 CloudLedger 客服后台的调查员379 个字符。「不直接回复客户」这句把调查和写回复分开,act 的输出只给 draft 用。1. 客户 ID 已经给你了实测 10 张工单里 act 一次都没调 get_customer,省了一轮。3. 只有当客户明确要求退款实测有效:「能退吗,先问问」那单查完 check_refund_eligibility 就停了,调查结论写的是「客户只是询问“能退多少”,并未明确要求退款,因此未创建退款申请」。5. 结束时不再调用工具,输出「调查结论」实测模型的结论是 4 条短句:订阅、账单、退款资格(eligible=true,950 元)、为什么没建申请。DRAFT = """你是 CloudLedger 的客服起草提示词。- 用到帮助中心资料的句子,在句末标注来源编号编号格式写死成[KB-xxx-000],finalize 才能用固定规则把它渲染成[1]。- 如果有待审批的退款申请:只能说提示词要求说「审核通过后会原路退回」。CHECK = """你是客服质检员212 个字符。1. 草稿里每一个具体事实这条很严。实测第 6 张(重复扣款)两版草稿都被挑出和调查结论对不上的句子,最后转了人工。我在开着思考的另一次实跑里还见过它和起草提示词打架:草稿按 DRAFT 的要求写「审核通过后会原路退回」,质检判「退款渠道没有依据」打回。两个提示词口径不一致,就会白花重写的钱。这些都是 Day 47 让你记进问题清单、Day 51 去修的那类问题。
所有提示词放一个文件,开头有 PROMPT_VERSION。Day 50 的评测报告会记录用的是哪个版本,Day 51 改提示词时就改版本号。提示词就是代码,要能 diff、能回滚、能对应到分数。
几个写法上的考虑:
分诊提示词把每个分类的边界写清楚了,尤其是 needs_account_data 举了正反例。模型分类出错,十有八九是因为分类定义模糊,而不是模型笨。
act 提示词第 2、3 条是退款的操作规程:先查账单、再判资格、符合条件且客户明确要求才建申请。这些规则在工具描述里也写了一遍。重复是故意的,工具描述是”这个工具怎么用”,系统提示是”这件事的流程是什么”,两处都有,模型遵守得更稳。
起草提示词明确禁止”已经退款""承诺到账时间”这类话。但我不完全信任提示词,质检节点还会用正则再查一遍。
依赖注入:让节点可以被测试
# supportpilot/src/supportpilot/agent/deps.py
"""节点需要的外部依赖都装在 Deps 里。生产环境用 from_conn() 组装;测试时换成假的,不连数据库不花钱。"""
from collections.abc import Callable
from dataclasses import dataclass
from functools import partial
from typing import Any
from ..config import Settings, get_settings
from ..kb.retriever import HybridRetriever
from ..llm import LLMClient
from ..tools import business as biz
from ..tools.registry import call_tool
@dataclass
class Deps:
llm: LLMClient # 起草用的主力模型
small_llm: LLMClient # 分类、质检用的便宜模型(Day 52 才真的分开,现在可以是同一个)
retriever: Any # 有 search(query, k) -> list[Hit] 就行
call_tool: Callable[..., tuple[str, bool]] # (name, arguments, ticket_id=...) -> (text, is_error)
escalate: Callable[[str, str], Any]
decide_refund: Callable[[str, bool, str, str], dict]
finish_ticket: Callable[[str, str, str | None, str], None]
settings: Settings
@classmethod
def from_conn(cls, conn, settings: Settings | None = None) -> "Deps":
s = settings or get_settings()
llm = LLMClient(s)
small = LLMClient(s, model=s.llm_small_model) if s.llm_small_model else llm
return cls(llm=llm, small_llm=small, retriever=HybridRetriever(conn, settings=s),
call_tool=partial(call_tool, conn),
escalate=partial(biz.escalate_to_human, conn),
decide_refund=partial(biz.decide_refund, conn),
finish_ticket=partial(biz.finish_ticket, conn), settings=s)
def spent(self) -> float:
"""两个客户端累计花了多少钱;同一个对象只算一次。"""
total = self.llm.ledger.cost_cny(self.settings)
if self.small_llm is not self.llm:
total += self.small_llm.ledger.cost_cny(self.settings)
return total
from functools import partialpartial(f, a)返回一个新函数,调用时自动把a填进第一个参数。class Deps:节点要用的外部依赖全装在这里,测试时整包换成假的。llm: LLMClient起草和 act 用的主力模型。small_llm: LLMClient分诊和质检用。retriever: Any鸭子类型:只要有search(query, k)方法就行,真的 HybridRetriever 和测试里的 FakeRetriever 都能塞进来。call_tool: Callable[..., tuple[str, bool]]Callable[..., X]的...表示参数不限,只约束返回值是 (文本, 是否出错)。decide_refund: Callable[[str, bool, str, str], dict]四个参数依次是申请号、是否通过、审批人、备注。def from_conn(cls, conn@classmethod的第一个参数是类本身,cls(...)就是构造一个 Deps。返回类型写成字符串"Deps"也是前向引用。small = LLMClient(s, model=s.llm_small_model) if s.llm_small_model else llm实测.env没配LLM_SMALL_MODEL,deps.small_llm is deps.llm是 True,两个名字指向同一个对象。retriever=HybridRetriever(conn, settings=s),Day 45 的混合检索,构造时要数据库连接。call_tool=partial(call_tool, conn),节点里写deps.call_tool("get_customer", {...}, ticket_id=...),实际执行的是call_tool(conn, "get_customer", {...}, ticket_id=...)。数据库连接在这里一次绑定好,节点代码里看不到它。def spent(self) -> float:两个客户端合起来一共花了多少钱。节点用「结束时读数减开始时读数」算出自己这一步的花费。if self.small_llm is not self.llm:is比较的是「是不是同一个对象」,不是值相等。实测是同一个对象,只算一次;不判断的话,同一本账会被算两遍。
节点需要的外部东西(大模型、检索器、工具、数据库操作)全装进一个 Deps。生产环境用 from_conn() 组装真的;测试时塞假的进去,节点代码一行都不用改。
call_tool、escalate 这些用 functools.partial 把数据库连接提前绑定好,节点调用时只需要传业务参数。节点代码里看不到任何数据库连接,它也不需要知道。
small_llm 现在和 llm 是同一个对象(配置里 LLM_SMALL_MODEL 留空),Day 52 做模型路由时才分开。spent() 里判断了是不是同一个对象,避免重复计费。
九个节点
# supportpilot/src/supportpilot/agent/nodes.py
import json
import re
from dataclasses import asdict
from langgraph.config import get_stream_writer
from langgraph.types import interrupt
from ..kb.cite import build_context, check_citations, render_sources
from ..kb.retriever import Hit
from ..tools.registry import Level, openai_tools
from . import prompts
from .deps import Deps
from .state import Review, SupportState, Triage
PROMISE_RE = re.compile(r"已(经)?(为您|给您)?(办理|完成)?退款(?!申请)|退款已(到账|完成|办理|退回)|马上到账")
def _ticket_text(state: SupportState) -> str:
t = state["ticket"]
return f"发件邮箱:{t['customer_email']}\n主题:{t['subject']}\n正文:{t['body']}"
def _emit(event: dict) -> None:
"""往 custom 流里推一条事件(工具调用、token 等)。非流式运行时什么都不做。"""
try:
get_stream_writer()(event)
except RuntimeError: # 不在 LangGraph 运行上下文里(比如单测直接调节点函数)
pass
def make_nodes(deps: Deps) -> dict:
"""返回 {节点名: 函数}。用闭包把 deps 带进去,图本身不关心依赖从哪来。"""
def load_customer(state: SupportState) -> dict:
text, err = deps.call_tool("get_customer", {"email": state["ticket"]["customer_email"]},
ticket_id=state["ticket"]["id"])
customer = None if err else json.loads(text)
_emit({"type": "step", "node": "load_customer", "found": customer is not None})
return {"customer": customer, "revisions": 0, "refund_request": None, "approval": None}
def classify(state: SupportState) -> dict:
before = deps.spent()
triage = deps.small_llm.structured(
[{"role": "system", "content": prompts.CLASSIFY}, {"role": "user", "content": _ticket_text(state)}],
Triage)
_emit({"type": "step", "node": "classify", "category": triage.category})
return {"triage": triage.model_dump(), "cost_cny": deps.spent() - before}
def retrieve(state: SupportState) -> dict:
tr = state["triage"]
query = tr.get("search_query") or f"{state['ticket']['subject']} {state['ticket']['body']}"
hits = deps.retriever.search(query, k=deps.settings.retrieve_k)
_emit({"type": "step", "node": "retrieve", "docs": [h.doc_id for h in hits]})
return {"hits": [asdict(h) for h in hits]}
def act(state: SupportState) -> dict:
before = deps.spent()
tid = state["ticket"]["id"]
cust = state["customer"]
msgs: list[dict] = [
{"role": "system", "content": prompts.ACT},
{"role": "user", "content": f"客户档案:{json.dumps(cust, ensure_ascii=False)}\n\n{_ticket_text(state)}\n\n"
f"分诊结论:{state['triage']['summary']}"},
]
log: list[dict] = []
refund_request = None
findings = ""
tools = openai_tools({Level.READ, Level.PROPOSE})
for step in range(deps.settings.max_act_steps):
res = deps.llm.chat(msgs, tools=tools, temperature=0)
if not res.tool_calls:
findings = res.content
break
msgs.append(res.raw_message)
for call in res.tool_calls:
text, err = deps.call_tool(call["name"], call["arguments"], ticket_id=tid, customer_id=cust["id"])
log.append({"step": step, "name": call["name"], "args": call["arguments"], "error": err,
"result": text[:500]})
_emit({"type": "tool", "name": call["name"], "args": call["arguments"], "error": err})
if call["name"] == "create_refund_request" and not err:
refund_request = json.loads(text)
msgs.append({"role": "tool", "tool_call_id": call["id"], "content": text})
else:
findings = "(调查达到步数上限,以下为已查到的工具结果)\n" + "\n".join(
f"- {x['name']}: {x['result'][:200]}" for x in log)
return {"tool_log": log, "findings": findings, "refund_request": refund_request,
"cost_cny": deps.spent() - before}
def draft(state: SupportState) -> dict:
before = deps.spent()
hits = [Hit(**h) for h in state.get("hits", [])]
parts = [f"【客户工单】\n{_ticket_text(state)}",
f"【帮助中心资料】\n{build_context(hits)}",
f"【调查结论】\n{state.get('findings') or '(没有查询客户数据)'}"]
if state.get("customer") is None and state["triage"].get("needs_account_data"):
parts.append("【注意】没有找到这个邮箱对应的客户账户。")
if state.get("refund_request"):
rr = state["refund_request"]
parts.append(f"【待审批退款申请】{rr['id']},金额 {rr['amount_cny']:.2f} 元,规则 {rr['rule']}")
if state.get("review") and not state["review"].get("passed"):
parts.append("【上一版草稿被质检退回,问题如下,请改正】\n" + "\n".join(state["review"]["problems"]))
res = deps.llm.stream(
[{"role": "system", "content": prompts.DRAFT}, {"role": "user", "content": "\n\n".join(parts)}],
on_token=lambda t: _emit({"type": "token", "text": t}))
return {"draft": res.content, "cost_cny": deps.spent() - before}
def check(state: SupportState) -> dict:
before = deps.spent()
hits = [Hit(**h) for h in state.get("hits", [])]
problems: list[str] = []
_, fake = check_citations(state["draft"], hits)
if fake:
problems.append(f"引用了不存在于资料中的编号:{', '.join(fake)}")
if PROMISE_RE.search(state["draft"]):
problems.append("草稿承诺了退款已完成/马上到账,退款必须经过人工审核")
if not problems: # 规则检查过了再花钱让模型检查
review = deps.small_llm.structured([
{"role": "system", "content": prompts.CHECK},
{"role": "user", "content": f"【客户工单】\n{_ticket_text(state)}\n\n【帮助中心资料】\n{build_context(hits)}"
f"\n\n【调查结论】\n{state.get('findings') or '无'}\n\n【回复草稿】\n{state['draft']}"},
], Review)
problems.extend(review.problems if not review.passed else [])
passed = not problems
_emit({"type": "step", "node": "check", "passed": passed, "problems": problems})
return {"review": {"passed": passed, "problems": problems},
"revisions": state.get("revisions", 0) + (0 if passed else 1),
"cost_cny": deps.spent() - before}
def approval(state: SupportState) -> dict:
rr = state["refund_request"]
# interrupt 之前的代码在恢复时会重跑一遍,所以这里不能有副作用(不写库、不发消息)
decision = interrupt({"type": "refund_approval", "ticket_id": state["ticket"]["id"], "request": rr,
"draft": state["draft"],
"needs_finance_review": rr["amount_cny"] > deps.settings.refund_review_threshold})
approved = bool(decision.get("approved"))
result = deps.decide_refund(rr["id"], approved, decision.get("approver", "unknown"), decision.get("note", ""))
if approved:
followup = f"补充:您的退款申请已审核通过,{rr['amount_cny']:.2f} 元将原路退回,到账时间以支付渠道为准。"
else:
followup = ("补充:很抱歉,您的退款申请经审核未通过" + (f",原因:{decision['note']}" if decision.get("note") else "")
+ "。如有疑问可以直接回复这个工单。")
return {"approval": {**decision, "result_status": result.get("status")}, "followup": followup}
def handoff(state: SupportState) -> dict:
reason = state.get("handoff_reason") or (
"客户要求人工或涉及投诉/安全" if state.get("triage", {}).get("needs_human")
else "草稿多次未通过质检:" + ";".join(state.get("review", {}).get("problems", [])))
rr = state.get("refund_request")
if rr and rr.get("status") == "pending":
# act 已经建了申请、但草稿没过质检就转人工:这条申请不会再走到 approval 节点,作废它,
# 免得它挂在审批列表里,有人点通过却没有对应的图可以恢复
deps.decide_refund(rr["id"], False, "system:handoff", "工单已转人工,这条申请作废,由人工客服重新判断")
deps.escalate(state["ticket"]["id"], reason)
reply = "您好,您的问题已经转给人工客服同事,会在 1 个工作日内通过这个工单回复您。"
return {"outcome": "escalated", "handoff_reason": reason, "reply": reply}
def finalize(state: SupportState) -> dict:
hits = [Hit(**h) for h in state.get("hits", [])]
reply = render_sources(state["draft"], hits)
if state.get("followup"):
reply += "\n\n" + state["followup"]
deps.finish_ticket(state["ticket"]["id"], "resolved", state["triage"]["category"], state["triage"]["summary"])
return {"outcome": "resolved", "reply": reply}
return {"load_customer": load_customer, "classify": classify, "retrieve": retrieve, "act": act,
"draft": draft, "check": check, "approval": approval, "handoff": handoff, "finalize": finalize}
from dataclasses import asdictasdict(hit)把 Hit 对象转成普通字典,状态里只存字典,checkpointer 才好序列化。from langgraph.config import get_stream_writer拿到当前运行的 custom 流写入函数,节点用它往外推事件。from langgraph.types import interrupt审批节点用它让图停下来等人。from ..kb.cite import build_context, check_citations, render_sources两个点表示上一级包supportpilot,..kb.cite就是supportpilot/kb/cite.py(Day 45 写的)。PROMISE_RE = re.compile(拆开读三段,用|连起来:
①已(经)?(为您|给您)?(办理|完成)?退款(?!申请)匹配「已退款」「已为您退款」「已经为您完成退款」,结尾的(?!申请)是否定前瞻,后面紧跟「申请」就不算;
②退款已(到账|完成|办理|退回);
③马上到账。
实测「已为您提交退款申请」「已为您办理退款申请」都不命中。但这条规则也会误伤:「账单已是『已退款』状态」命中了已退款。端到端时第 4 张(李娜「先问问」)就栽在这里:第 3 张批准后那张账单已经退了,两版草稿都如实写了「目前状态为已退款」,都被当成违规承诺打回,最后转了人工。描述状态被当成承诺,这是 Day 51 要修的误判。def _ticket_text(state: SupportState) -> str:实测拼出来是发件邮箱:lina@example.com\n主题:想退款\n正文:上周买的专业版年付,发现用不上,请帮我退款。。分诊、act、起草、质检都用这一份。get_stream_writer()(event)两对括号:先调用拿到写入函数,再用 event 调用它。except RuntimeError:单测里直接调节点函数时不在 LangGraph 运行上下文里,get_stream_writer()会抛 RuntimeError,吞掉即可。def make_nodes(deps: Deps) -> dict:九个节点都定义在这个函数里面,形成闭包,每个节点都能直接用deps,图本身不需要知道依赖从哪来。text, err = deps.call_tool("get_customer"用发件邮箱查客户。这一步不调模型,零成本。customer = None if err else json.loads(text)实测李娜返回 C0002 的档案,事件是{'found': True};第 9 张nobody@example.com查不到,customer 是 None,事件是{'found': False}。return {"customer": customer, "revisions": 0, "refund_request": None, "approval": None}顺手把几个计数器和标志初始化,后面的节点就不用处处判断键在不在。before = deps.spent()记下账本读数,节点结束时用差值算这一步花了多少。triage = deps.small_llm.structured(实测第 3 张的分诊是{"category": "refund", "needs_account_data": true, "needs_human": false, "urgency": "normal", "summary": "客户要求退上周购买的专业版年付费用", "search_query": "专业版 年付 退款"}。return {"triage": triage.model_dump(), "cost_cny": deps.spent() - before}实测分诊这一步花了 0.00168 元。query = tr.get("search_query") or f"{state['ticket']['subject']}分诊没给检索词时退回用主题加正文。f-string 里取字典值用单引号,因为外层是双引号。hits = deps.retriever.search(query, k=deps.settings.retrieve_k)k 是 5。实测「能退吗」那单检索到KB-refund-001、KB-refund-001、KB-refund-003、KB-refund-001、KB-refund-002,分数 0.0328 到 0.0306。分数这么小是因为用的是 RRF 融合分;同一篇 KB-refund-001 出现三次,是命中了它的三个小节。return {"hits": [asdict(h) for h in hits]}存进状态前转成字典。cust = state["customer"]走到 act 一定有客户,路由after_retrieve保证了这一点,所以这里直接用[]取。{"role": "user", "content": f"客户档案:{json.dumps(cust, ensure_ascii=False)}客户档案直接放进消息,提示词又说「不用再查 get_customer」,省一轮工具调用。tools = openai_tools({Level.READ, Level.PROPOSE})只给只读和「提申请」两级的工具。实测是 7 个:get_customer、list_subscriptions、list_invoices、check_refund_eligibility、search_past_tickets、create_refund_request、query_customer_data。真正执行退款的工具不在里面,模型想退也退不了。for step in range(deps.settings.max_act_steps):最多 5 轮模型调用。res = deps.llm.chat(msgs, tools=tools, temperature=0)温度 0,调查这一步要稳定,不要发挥。if not res.tool_calls:模型不再调工具,说明它认为查够了,这次的文字就是调查结论,break 跳出循环。msgs.append(res.raw_message)先把 assistant 的工具调用放进历史,下面的 tool 结果才有对应的 id。for call in res.tool_calls:一轮可能有好几个调用。实测「能退吗」:step 0 同时调了 list_subscriptions 和 list_invoices,step 1 调 check_refund_eligibility,step 2 不再调工具,输出结论。一共 3 轮模型调用。text, err = deps.call_tool(call["name"], call["arguments"], ticket_id=tid, customer_id=cust["id"])实测 check_refund_eligibility 返回{"invoice_id": "INV-202609-0002", "eligible": true, "rule": "annual_first_14_days", "reason": "年付首次购买第 7 天,在 14 天内,全额退款。", "refund_amount_cny": 950.0, ...}。log.append({"step": step, "name": call["name"]result截到 500 字,日志不至于太大。if call["name"] == "create_refund_request" and not err:实测第 3 张建了RF-T000153-INV-202609-0002,950 元,status 是 pending;「先问问」那张没建,refund_request 保持 None。msgs.append({"role": "tool", "tool_call_id": call["id"], "content": text})工具结果按 id 喂回模型。工具报错(err=True)也照样喂回去,让模型自己改参数。else:这个 else 属于 for,不属于 if:for 循环正常跑完、中间一次都没 break,才会执行它,也就是 5 轮用完模型还在调工具。findings = "(调查达到步数上限把已经查到的结果拼成结论,前面花的钱不白花。实测 10 张演示工单都没有走到这里。return {"tool_log": log, "findings": findings, "refund_request": refund_request,实测「能退吗」这一步花了 0.01329 元,是整单里最贵的一步:三轮调用,每轮都带着越来越长的工具结果。hits = [Hit(**h) for h in state.get("hits", [])]把字典还原成 Hit 对象,**h把字典展开成关键字参数。parts = [f"【客户工单】用中文方括号把几块材料分开,提示词里引用的就是这些标题。f"【调查结论】\n{state.get('findings') or '(没有查询客户数据)'}"]没走 act 的工单(比如专票、报错码)这里就是固定的一句话。if state.get("customer") is None and state["triage"].get("needs_account_data"):实测第 9 张 nobody 走到这里,草稿里写了「请提供注册邮箱」。if state.get("refund_request"):实测第 3 张拼出【待审批退款申请】RF-T000153-INV-202609-0002,金额 950.00 元,规则 annual_first_14_days。:.2f把 float 格式化成两位小数。if state.get("review") and not state["review"].get("passed"):质检打回后再进 draft,会多出这一块。实测第 9 张(找不到客户)第一版被退回三条意见,第二版改成「目前尚未能定位到对应账户,请您提供注册邮箱」,并补上了退款流程,质检通过。res = deps.llm.stream(流式生成,每段文字通过_emit推出去。实测第 3 张 draft 这一步花了 0.00216 元。_, fake = check_citations(state["draft"], hits)check_citations返回两样,第一样用不上,就用_接住。fake是草稿里出现、但检索结果里没有的编号。if PROMISE_RE.search(state["draft"]):规则二。实测第 4 张两版草稿都在这里被拦,原因见上面 PROMISE_RE 那条。if not problems:规则检查都过了才花钱让模型检查;规则已经发现问题,就不再调模型。【回复草稿】\n{state['draft']}注意质检模型拿到的是工单、资料、调查结论和草稿,拿不到 draft 那一步加的「【注意】没有找到这个邮箱对应的客户账户」。实测第 9 张第一版写「目前我们未能查询到该邮箱对应的客户账户」,质检判「调查结论为『无』,没有任何依据」打回。这又是一条可以记进问题清单的。problems.extend(review.problems if not review.passed else [])模型判不过时把它列的问题并进来。"revisions": state.get("revisions", 0) + (0 if passed else 1),实测第 3 张一次通过,revisions 保持 0;第 9 张第一次不过变成 1,第二次通过保持 1;第 6 张两次都不过,变成 2,转人工。decision = interrupt({"type": "refund_approval"图在这里停下,这个字典交给外面。实测 payload 的 request 是{"id": "RF-T000153-INV-202609-0002", "amount_cny": 950.0, "rule": "annual_first_14_days", "status": "pending", "note": "已创建待审批的退款申请。不要告诉客户退款已完成,只能说已提交审核。", ...}。"needs_finance_review": rr["amount_cny"] > deps.settings.refund_review_threshold})950 > 1000 是 False,审批面板上不标红。approved = bool(decision.get("approved"))恢复时Command(resume=...)传进来的字典,就是这里interrupt()的返回值。result = deps.decide_refund(rr["id"], approved人的决定落到数据库。实测批准后result_status是executed。followup = f"补充:您的退款申请已审核通过实测是补充:您的退款申请已审核通过,950.00 元将原路退回,到账时间以支付渠道为准。。金额是 float,直接拼会显示成950.0 元;:.2f统一成两位小数,和 Day 23 的yuan()一个写法。followup = ("补充:很抱歉,您的退款申请经审核未通过"实测第 5 张选了 n,备注是「演示驳回」,得到补充:很抱歉,您的退款申请经审核未通过,原因:演示驳回。如有疑问可以直接回复这个工单。。reason = state.get("handoff_reason") or (实测投诉单的原因是「客户要求人工或涉及投诉/安全」;第 4 张是「草稿多次未通过质检:草稿承诺了退款已完成/马上到账,退款必须经过人工审核」;第 6 张是「草稿多次未通过质检:草稿称『另一笔为正常扣款』,但调查结论明确……」。rr = state.get("refund_request")转人工前先看 act 有没有建过退款申请。if rr and rr.get("status") == "pending":只有「申请建了、草稿两次没过质检」这条路会带着 pending 申请走到这里;直接转人工的工单(投诉、needs_human)根本没进 act,rr 是 None。deps.decide_refund(rr["id"], False, "system:handoff"把申请标成 rejected 并写明原因。实测第 6 张(重复扣款)两次质检不过转人工后,库里这条是RF-T000154-INV-202609-0004 | rejected | system:handoff | 工单已转人工,这条申请作废,由人工客服重新判断,pending 的申请一条都没剩,审批列表里不会再出现它。不作废的话,它会一直挂在 Day 49 的审批列表里,点通过返回 409。reply = "您好,您的问题已经转给人工客服同事转人工时给客户的固定话术,不调模型。reply = render_sources(state["draft"], hits)把[KB-refund-001]换成[1],文末加「参考文章」和链接。deps.finish_ticket(state["ticket"]["id"], "resolved"更新工单状态、分类和摘要。return {"load_customer": load_customer, "classify": classify返回节点名到函数的字典,build_graph遍历它注册节点。
逐个说。
load_customer:用发件邮箱查客户。查不到不报错,customer 为 None,后面的节点会据此让客户提供注册邮箱。这一步不调大模型,零成本。
classify:分诊。用小模型、温度 0、结构化输出。输出分类、是否需要查账户、是否需要转人工、紧急程度、一句话摘要、检索查询。
retrieve:用分诊给的 search_query 去检索。如果分诊没给就用原文。
act:全图唯一的 agent 循环,和 Day 9 手写的那个结构一样:调模型 → 有工具调用就执行 → 结果喂回 → 直到模型不再调工具。区别在于加了这些:
- 步数上限
max_act_steps=5,用for ... else写:循环正常跑完(没 break)说明到了上限,这时把已经查到的工具结果拼成调查结论,不浪费已经花的钱。 - 每次工具调用都记进
tool_log,并通过_emit推一个事件给前端。 - 工具错误(
err=True)照常喂回模型,让它自己改参数重试。 create_refund_request成功时,把返回的申请记进refund_request,后面的路由要用。- 客户 ID 已经放进了用户消息,提示词也说”不需要再查 get_customer”,省一轮。
draft:起草。把工单、检索资料(带编号)、调查结论、待审批申请、上一版被退回的原因全拼进去。用流式生成,每段文字通过 _emit 推出去,Day 49 的界面上就能看到打字机效果。
check:质检,先规则后模型。规则一:引用了检索结果里没有的编号。规则二:正则匹配到”已为您退款""马上到账”这类违规承诺。规则查出问题就不再调模型,省钱;规则过了才让小模型核对事实依据。这就是 Day 13 的 evaluator-optimizer:不通过就带着问题清单回到 draft 重写。
approval:退款审批。调用 interrupt(),图在这里停下,把申请详情交给外面。人的决定通过 Command(resume=...) 传回来,成为 interrupt() 的返回值。然后调 decide_refund 执行或驳回,写一句补充说明给客户。
这里有个 LangGraph 的规则一定要记住:**节点恢复时会从头重跑,interrupt() 之前的代码会执行两次。**所以 interrupt 之前只放没有副作用的代码(构造 payload),写库、发通知都放在 interrupt 之后或者别的节点里。Day 46 把审批通知放在工具层就是这个原因。
handoff:转人工。调 escalate 把工单标为 escalated,给客户一句固定的话。原因写进 handoff_reason,人工接手时能看到。还有一种情况要收尾:act 已经建了退款申请,草稿却两次没过质检转了人工。这条申请永远走不到 approval 节点,如果留着 pending,它会出现在 Day 49 的审批列表里,有人点通过却没有对应的图可以恢复(Day 49 实测返回 409)。所以转人工前把它作废(标成 rejected 并写明原因),要不要退由接手的人重新判断。
finalize:定稿。把草稿里的 [KB-xxx] 渲染成 [1] [2] 和文末链接,有审批结果的话加上补充说明,更新工单状态。
_emit 包了一层 try。get_stream_writer() 只在 LangGraph 运行时有效,单测里直接调节点函数时没有运行上下文,包一层让节点在任何地方都能调。
图和路由
# supportpilot/src/supportpilot/agent/graph.py
from langgraph.graph import END, START, StateGraph
from .deps import Deps
from .nodes import make_nodes
from .state import SupportState
def after_classify(state: SupportState) -> str:
return "handoff" if state["triage"]["needs_human"] else "retrieve"
def after_retrieve(state: SupportState) -> str:
if state["triage"]["needs_account_data"] and state.get("customer"):
return "act"
return "draft"
def after_check(deps: Deps):
def route(state: SupportState) -> str:
if state["review"]["passed"]:
rr = state.get("refund_request")
return "approval" if rr and rr.get("status") == "pending" else "finalize"
if state.get("revisions", 0) <= deps.settings.max_revisions:
return "draft"
return "handoff"
return route
def build_graph(deps: Deps, checkpointer=None):
n = make_nodes(deps)
g = StateGraph(SupportState)
for name, fn in n.items():
g.add_node(name, fn)
g.add_edge(START, "load_customer")
g.add_edge("load_customer", "classify")
g.add_conditional_edges("classify", after_classify, ["retrieve", "handoff"])
g.add_conditional_edges("retrieve", after_retrieve, ["act", "draft"])
g.add_edge("act", "draft")
g.add_edge("draft", "check")
g.add_conditional_edges("check", after_check(deps), ["approval", "finalize", "draft", "handoff"])
g.add_edge("approval", "finalize")
g.add_edge("finalize", END)
g.add_edge("handoff", END)
return g.compile(checkpointer=checkpointer)
from langgraph.graph import END, START, StateGraphSTART、END 是图的入口和出口。def after_classify(state: SupportState) -> str:返回下一个节点的名字。实测第 10 张投诉单 needs_human 是 true,走 handoff;其余 9 张走 retrieve。def after_retrieve(state: SupportState) -> str:要查账而且找到了客户才进 act。实测:李娜退款单 → act;第 1 张专票 needs_account_data 是 false → draft;第 9 张 nobody 没有客户 → draft。def after_check(deps: Deps):路由函数要读max_revisions,所以写成「返回一个函数的函数」,用闭包把 deps 带进去。return "approval" if rr and rr.get("status") == "pending" else "finalize"质检过了:有待审批的申请就去审批,没有就定稿。if state.get("revisions", 0) <= deps.settings.max_revisions:实测路由表(max_revisions=1):
passed=False、revisions=1 → draft;
passed=False、revisions=2 → handoff;
passed=True、有 pending 申请 → approval;
passed=True、没有申请 → finalize。n = make_nodes(deps)拿到九个节点函数。g = StateGraph(SupportState)用状态的类型建图,reducer(tool_log、cost_cny 的累加)从这里读出来。for name, fn in n.items():逐个注册节点。g.add_edge(START, "load_customer")普通边,固定走向。g.add_conditional_edges("classify", after_classify, ["retrieve", "handoff"])条件边:跑完 classify 调after_classify,按返回的名字走。第三个参数列出所有可能的去处。g.add_conditional_edges("check", after_check(deps), ["approval", "finalize", "draft", "handoff"])注意这里是after_check(deps),先调用一次拿到真正的路由函数。return g.compile(checkpointer=checkpointer)不传 checkpointer 也能编译,只是遇到 interrupt 停下后状态没处存,无法恢复。
四个路由函数都是纯函数,只看状态里的字段做判断,不调模型。分支逻辑一目了然,出了问题看一眼状态就知道为什么走了这条路。
after_check 里的 revisions <= max_revisions:第一次质检不过,revisions 变成 1,1 ≤ 1,回去重写;第二次还不过,revisions 变成 2,2 > 1,转人工。也就是说最多重写一次。为什么不多给几次?因为同一个模型、同样的资料,第三次写出来通常和第二次差不多,多重试只是多花钱。两次都过不了,说明资料不够或者问题超出了能力范围,该交给人。
add_conditional_edges 的第三个参数列出了所有可能去的节点。不写也能跑,但写上之后 LangGraph 能在编译时检查,画图时也能画出所有分支。
为什么不用现成的预置 agent
LangGraph 有现成的预置 ReAct agent,几行代码就能得到一个”模型 + 工具”的循环,Day 24 你用过。为什么 act 节点要自己写?
一是要控制。我需要在工具调用之间插入自己的逻辑:记录日志、推送事件、识别退款申请、步数用完时把已查到的结果整理成结论。预置 agent 也能通过钩子做到一部分,但每加一样就要去翻一次文档看它支持不支持。自己写的循环就二十几行,想加什么直接加。
二是要看得懂。面试时被问”你的 agent 循环是怎么实现的”,指着自己写的二十行讲清楚,比说”我调了某个函数”有说服力得多。Day 9 手写过,这里只是把它放进了产品代码。
如果你的场景就是标准的”模型加几个工具”,没有这些定制需求,用预置的完全没问题。框架是工具,不是信仰。
单测:不花一分钱验证整条流程
工作流的逻辑(路由对不对、审批停没停、质检打回后有没有重写)和大模型的能力是两回事。前者用假 LLM 测,确定、免费、几秒钟;后者 Day 50 用评测测。
# supportpilot/tests/fakes.py
"""假的 LLM / 检索 / 工具,让工作流的单测不连数据库、不花钱、结果确定。"""
import json
from supportpilot.agent import prompts
from supportpilot.agent.deps import Deps
from supportpilot.config import Settings
from supportpilot.kb.retriever import Hit
from supportpilot.llm import ChatResult, Usage
class FakeLLM:
def __init__(self, triage: dict, act_script: list[list[tuple[str, dict]]], draft: str, review_ok: bool = True):
self.triage, self.act_script, self.draft_text, self.review_ok = triage, list(act_script), draft, review_ok
self.ledger = Usage()
def structured(self, messages, schema, **_):
self.ledger.add(Usage(100, 20, 1))
if messages[0]["content"] == prompts.CLASSIFY:
return schema(**self.triage)
return schema(passed=self.review_ok, problems=[] if self.review_ok else ["金额没有依据"])
def chat(self, messages, tools=None, **_):
self.ledger.add(Usage(300, 50, 1))
if not self.act_script:
return ChatResult(content="调查结论:- 已查清")
calls = [{"id": f"c{i}", "name": n, "arguments": json.dumps(a)} for i, (n, a) in
enumerate(self.act_script.pop(0))]
raw = {"role": "assistant", "content": "", "tool_calls": [
{"id": c["id"], "type": "function", "function": {"name": c["name"], "arguments": c["arguments"]}}
for c in calls]}
return ChatResult(content="", tool_calls=calls, raw_message=raw)
def stream(self, messages, on_token=None, **_):
self.ledger.add(Usage(800, 150, 1))
for piece in (self.draft_text[:10], self.draft_text[10:]):
if on_token:
on_token(piece)
return ChatResult(content=self.draft_text)
class FakeRetriever:
def search(self, query, k=5):
return [Hit(1, "KB-refund-002", "年付 14 天无理由退款", "条件", "首次购买的年付订阅 14 天内全额退款。",
"/help/refund/002", "refund", 0.03, ["vector", "bm25"])]
class FakeTools:
def __init__(self, customer: dict | None = None):
self.customer = customer
self.calls: list[str] = []
self.decided: list[tuple] = []
self.escalated: list[str] = []
self.finished: list[tuple] = []
def __call__(self, name, arguments, *, ticket_id, customer_id=None):
self.calls.append(name)
args = json.loads(arguments) if isinstance(arguments, str) else arguments
if name == "get_customer":
return (json.dumps(self.customer), False) if self.customer else ("没有找到客户", True)
if name == "list_invoices":
return json.dumps({"total": 1, "returned": 1, "invoices": [{"id": "INV-202609-0001"}]}), False
if name == "check_refund_eligibility":
return json.dumps({"invoice_id": args["invoice_id"], "eligible": True, "refund_amount_cny": 950.0,
"rule": "annual_first_14_days"}), False
if name == "create_refund_request":
return json.dumps({"id": f"RF-{ticket_id}-{args['invoice_id']}", "status": "pending",
"amount_cny": 950.0, "rule": "annual_first_14_days"}), False
return "未知工具", True
def make_deps(llm: FakeLLM, tools: FakeTools) -> Deps:
return Deps(llm=llm, small_llm=llm, retriever=FakeRetriever(), call_tool=tools,
escalate=lambda tid, reason: tools.escalated.append(tid),
decide_refund=lambda rid, ok, who, note: (tools.decided.append((rid, ok)) or
{"status": "executed" if ok else "rejected"}),
finish_ticket=lambda *a: tools.finished.append(a), settings=Settings())
class FakeLLM:冒充 LLMClient,只实现节点用到的structured、chat、stream三个方法和ledger。self.triage, self.act_script, self.draft_text, self.review_ok = triage, list(act_script), draft, review_ok一行同时给四个属性赋值。list(act_script)复制一份,后面pop不会改到测试文件里的常量 ACT。def structured(self, messages, schema, **_):**_吞掉用不上的关键字参数(retries、model)。self.ledger.add(Usage(100, 20, 1))位置参数依次是输入、输出、次数。每次结构化调用记 100 / 20。if messages[0]["content"] == prompts.CLASSIFY:看系统提示认出是哪个节点在调用:分诊返回预设结果,其余就是质检。return schema(passed=self.review_ok质检结果。review_ok=False时固定返回一条问题「金额没有依据」。def chat(self, messages, tools=None, **_):act 用的。每次记 300 / 50。if not self.act_script:剧本用完就返回调查结论,act 循环会 break。calls = [{"id": f"c{i}", "name": n, "arguments": json.dumps(a)}剧本里的一轮转成工具调用列表。enumerate给每个调用编号 c0、c1……def stream(self, messages, on_token=None, **_):起草用的,每次记 800 / 150。for piece in (self.draft_text[:10], self.draft_text[10:]):把草稿切成两段,模拟流式推送。class FakeRetriever:不管问什么都返回同一条资料 KB-refund-002。return [Hit(1, "KB-refund-002"按位置依次是 chunk_id、doc_id、title、heading、text、url_path、category、score、via。class FakeTools:冒充工具层,同时记下调用了什么。def __call__(self, name, arguments, *, ticket_id, customer_id=None):定义了__call__,实例就能像函数一样被调用,所以能直接塞进Deps.call_tool。args = json.loads(arguments) if isinstance(arguments, str) else argumentsload_customer 传的是字典,act 传的是模型给的 JSON 字符串,两种都接。if name == "get_customer":有客户就返回档案,没有就返回错误。return json.dumps({"invoice_id": args["invoice_id"], "eligible": True, "refund_amount_cny": 950.0,资格检查固定判符合,950 元。return json.dumps({"id": f"RF-{ticket_id}-{args['invoice_id']}", "status": "pending",申请号的拼法和真实工具一致。def make_deps(llm: FakeLLM, tools: FakeTools) -> Deps:用假零件组装一个 Deps,大模型、小模型都是同一个 FakeLLM。decide_refund=lambda rid, ok, who, note: (tools.decided.append((rid, ok)) orappend返回 None,None or {...}取右边。一行 lambda 里既记录又返回结果,用的就是这个小技巧。finish_ticket=lambda *a: tools.finished.append(a), settings=Settings())*a把所有位置参数收成元组。实测退款测试结束后 finished 是[('T000900', 'resolved', 'refund', '年付首购想退款')]。
FakeLLM 按系统提示判断自己被哪个节点调用:分诊时返回预设的分诊结果,act 时按剧本依次返回工具调用,起草时返回预设草稿。FakeTools 模拟工具返回,同时记录被调用了哪些工具、审批了什么、转人工了什么,测试里可以断言。
# supportpilot/tests/test_graph_smoke.py
import pytest
from fakes import FakeLLM, FakeTools, make_deps
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import Command
from supportpilot.agent.graph import build_graph
TICKET = {"id": "T000900", "customer_email": "lina@example.com", "subject": "想退款",
"body": "上周买的专业版年付,用不上,请帮我退款。"}
REFUND_TRIAGE = {"category": "refund", "needs_account_data": True, "needs_human": False, "urgency": "normal",
"summary": "年付首购想退款", "search_query": "年付 退款"}
ACT = [[("list_invoices", {"customer_id": "C0002"})],
[("check_refund_eligibility", {"invoice_id": "INV-202609-0001"})],
[("create_refund_request", {"invoice_id": "INV-202609-0001", "reason": "首购 7 天不需要"})]]
DRAFT = "您好,您的年付订阅在 14 天内,符合全额退款条件 [KB-refund-002]。已提交退款申请,审核通过后会原路退回。"
def test_refund_goes_through_approval_then_resumes():
tools = FakeTools(customer={"id": "C0002", "email": "lina@example.com"})
graph = build_graph(make_deps(FakeLLM(REFUND_TRIAGE, ACT, DRAFT), tools), checkpointer=InMemorySaver())
cfg = {"configurable": {"thread_id": TICKET["id"]}}
out = graph.invoke({"ticket": TICKET}, cfg)
assert "__interrupt__" in out # 停在审批节点
payload = out["__interrupt__"][0].value
assert payload["request"]["status"] == "pending"
assert tools.calls == ["get_customer", "list_invoices", "check_refund_eligibility", "create_refund_request"]
assert tools.decided == [] # 人还没点,钱一分没动
out = graph.invoke(Command(resume={"approved": True, "approver": "finance-amy"}), cfg)
assert out["outcome"] == "resolved"
assert tools.decided == [("RF-T000900-INV-202609-0001", True)]
assert "[1]" in out["reply"] and "参考文章" in out["reply"]
assert out["cost_cny"] > 0
def test_handoff_withdraws_pending_refund():
tools = FakeTools(customer={"id": "C0002", "email": "lina@example.com"})
deps = make_deps(FakeLLM(REFUND_TRIAGE, ACT, DRAFT, review_ok=False), tools)
out = build_graph(deps).invoke({"ticket": TICKET})
assert out["outcome"] == "escalated"
assert tools.decided == [("RF-T000900-INV-202609-0001", False)] # 申请被作废,不会留在审批列表里
def test_policy_question_skips_tools():
triage = {**REFUND_TRIAGE, "category": "invoice", "needs_account_data": False, "summary": "专票怎么开"}
tools = FakeTools(customer={"id": "C0001"})
graph = build_graph(make_deps(FakeLLM(triage, [], DRAFT), tools))
out = graph.invoke({"ticket": {**TICKET, "body": "专票怎么开"}})
assert tools.calls == ["get_customer"] # 没进 act
assert out["outcome"] == "resolved"
def test_angry_customer_goes_to_human():
triage = {**REFUND_TRIAGE, "needs_human": True}
tools = FakeTools(customer={"id": "C0002"})
out = build_graph(make_deps(FakeLLM(triage, [], DRAFT), tools)).invoke({"ticket": TICKET})
assert out["outcome"] == "escalated" and tools.escalated == ["T000900"]
def test_failed_review_twice_escalates():
tools = FakeTools(customer={"id": "C0001"})
triage = {**REFUND_TRIAGE, "needs_account_data": False}
out = build_graph(make_deps(FakeLLM(triage, [], DRAFT, review_ok=False), tools)).invoke({"ticket": TICKET})
assert out["outcome"] == "escalated"
assert out["revisions"] == 2
def test_promise_rule_blocks_draft_without_llm():
tools = FakeTools(customer={"id": "C0001"})
triage = {**REFUND_TRIAGE, "needs_account_data": False}
bad = "您好,已为您退款,马上到账 [KB-refund-002]。"
out = build_graph(make_deps(FakeLLM(triage, [], bad), tools)).invoke({"ticket": TICKET})
assert out["outcome"] == "escalated"
assert any("承诺" in p for p in out["review"]["problems"])
@pytest.mark.parametrize("text,bad", [
("已为您退款,马上到账", True), ("退款已到账", True), ("已为您完成退款", True),
("已为您提交退款申请,审核通过后原路退回", False), ("已为您办理退款申请", False),
])
def test_promise_regex(text, bad):
from supportpilot.agent.nodes import PROMISE_RE
assert bool(PROMISE_RE.search(text)) is bad
from fakes import FakeLLM, FakeTools, make_deps直接导入同目录的 fakes.py。pytest 收集 tests 目录时会把它加进搜索路径。from langgraph.checkpoint.memory import InMemorySaver内存版存档,审批停下后状态存在这里。TICKET = {"id": "T000900"固定的测试工单。ACT = [[("list_invoices"三轮剧本,每轮一个工具调用:查账单、查资格、建申请。DRAFT = "您好,您的年付订阅在 14 天内合规草稿,带一个引用编号。def test_refund_goes_through_approval_then_resumes():实测 14 个测试(含 test_llm 的 3 个)全部通过,0.99 秒。cfg = {"configurable": {"thread_id": TICKET["id"]}}thread_id 区分不同工单的存档,恢复时要用同一个。out = graph.invoke({"ticket": TICKET}, cfg)实测停下时 out 里有__interrupt__,payload 的键是 type、ticket_id、request、draft、needs_finance_review(950 < 1000,所以是 False)。assert tools.calls == ["get_customer", "list_invoices", "check_refund_eligibility", "create_refund_request"]实测完全一致。这时假 LLM 被调了 7 次:分诊 1、act 4(3 轮工具加 1 轮结论)、起草 1、质检 1。cost_cny 是 0.00752:输入 2200 × 2 / 1e6 + 输出 390 × 8 / 1e6。out = graph.invoke(Command(resume={"approved": True, "approver": "finance-amy"}), cfg)带着同一个 cfg 恢复,图从 approval 节点接着跑。assert tools.decided == [("RF-T000900-INV-202609-0001", True)]人点了通过,才真正执行。assert "[1]" in out["reply"] and "参考文章" in out["reply"]实测 reply 是「您好,您的年付订阅在 14 天内,符合全额退款条件 [1]。已提交退款申请,审核通过后会原路退回。」加上「参考文章:[1] 年付 14 天无理由退款 https://help.cloudledger.example/help/refund/002」和那句审核通过的补充。assert out["cost_cny"] > 0恢复之后 approval 和 finalize 都不调模型,实测 cost_cny 还是 0.00752。def test_handoff_withdraws_pending_refund():退款剧本照走,但质检永远不过:申请建了,草稿却过不了关。deps = make_deps(FakeLLM(REFUND_TRIAGE, ACT, DRAFT, review_ok=False), tools)没传 checkpointer,这条路不会停在审批,质检两次不过直接转人工。assert tools.decided == [("RF-T000900-INV-202609-0001", False)]实测 handoff 用False调了一次 decide_refund,申请被作废。没有这几行代码时 decided 是空列表,申请会一直 pending。triage = {**REFUND_TRIAGE, "category": "invoice", "needs_account_data": False{**旧字典, 键: 新值}复制一份再改几个字段。graph = build_graph(make_deps(FakeLLM(triage, [], DRAFT), tools))没传 checkpointer:这条路不会停,用不着存档。assert tools.calls == ["get_customer"]只调了 load_customer 的那一次,没进 act。def test_angry_customer_goes_to_human():needs_human 为 true,classify 之后直接转人工。def test_failed_review_twice_escalates():实测假 LLM 一共被调 5 次(分诊、起草、质检、起草、质检),revisions 0 → 1 → 2,handoff_reason 是「草稿多次未通过质检:金额没有依据」。def test_promise_rule_blocks_draft_without_llm():实测假 LLM 只被调了 3 次(分诊加两次起草),两次质检都被正则拦下,一次模型质检都没花。@pytest.mark.parametrize("text,bad", [一个测试函数配 5 组数据,pytest 会展开成 5 个测试。def test_promise_regex(text, bad):5 组全部通过。我另外补测了「账单已是『已退款』状态」,它也会命中已退款,这组不在用例里,端到端时就踩到了。
前六个测试各守一条底线:
- 退款工单一定会停在审批节点,人没点之前
decided是空的,一分钱没动;点了通过之后才执行,并且最终回复里引用被渲染成了[1]和链接。 - 退款申请已经建了、草稿却两次没过质检转人工时,申请被作废,不会留在审批列表里。
- 政策问题不进 act,工具只调了
get_customer。 needs_human直接转人工。- 质检两次不过就转人工,revisions 是 2。
- 草稿里有”已为您退款,马上到账”,规则检查直接拦下,而且根本没走到模型质检。
最后一个参数化测试是给质检正则本身的。我第一版正则把”已为您办理退款申请”也当成了违规承诺,这是一句完全合规的话,误判会让好草稿被打回、白花一次重写的钱。判断规则也是代码,也会写错,也要测。
这些测试我在本机都跑过,全部通过。InMemorySaver 是内存版的 checkpointer,审批需要它:没有 checkpointer,图停下后状态就丢了,没法恢复。
端到端跑一遍
{"customer_email": "zhangwei@example.com", "subject": "怎么开专票", "body": "我们公司要增值税专用发票,需要准备什么?"}
{"customer_email": "zhangwei@example.com", "subject": "导入失败", "body": "导入凭证一直提示 E2001,文件是从老系统导出的 xls。"}
{"customer_email": "lina@example.com", "subject": "想退款", "body": "上周买的专业版年付,发现用不上,请帮我退款。"}
{"customer_email": "lina@example.com", "subject": "能退吗", "body": "如果我现在不想用了,年付能退多少?先问问。"}
{"customer_email": "wangfang@example.com", "subject": "申请退款", "body": "5 月份买的专业版年付,现在用不上了,请退款。"}
{"customer_email": "liuyang@example.com", "subject": "扣了两次", "body": "9 月 12 号被扣了两次 39 元,请把多的退给我。"}
{"customer_email": "chenjing@example.com", "subject": "月费退款", "body": "这个月的 39 元请退给我,我不用了。"}
{"customer_email": "yanglei@example.com", "subject": "账套变只读了", "body": "为什么突然不能新增凭证了?"}
{"customer_email": "nobody@example.com", "subject": "退款", "body": "请把我的年费退了。"}
{"customer_email": "chenjing@example.com", "subject": "投诉", "body": "你们乱扣费,再不处理我就去 12315 投诉。"}
10 张工单覆盖了主要路径:政策问答、报错码、退款要求、退款咨询、按比例退款、重复扣款、月付退款被拒、扣款失败、找不到客户、投诉转人工。
# supportpilot/scripts/run_tickets.py
"""端到端跑 data/seed/demo_tickets.jsonl 里的 10 张工单,终端里看每一步。遇到退款审批会停下来问你。
uv run python scripts/run_tickets.py # 全部
uv run python scripts/run_tickets.py 3 # 只跑第 3 张
"""
import json
import sys
from pathlib import Path
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import Command
from supportpilot.agent.deps import Deps
from supportpilot.agent.graph import build_graph
from supportpilot.db import connect
from supportpilot.tools import business as biz
DEMO = Path(__file__).resolve().parent.parent / "data" / "seed" / "demo_tickets.jsonl"
def show(graph, payload, cfg) -> None:
for mode, chunk in graph.stream(payload, cfg, stream_mode=["updates", "custom"]):
if mode == "custom":
if chunk["type"] == "token":
print(chunk["text"], end="", flush=True)
elif chunk["type"] == "tool":
print(f"\n 🔧 {chunk['name']}({chunk['args']}){' ✗' if chunk['error'] else ''}")
elif chunk["type"] == "step":
info = {k: v for k, v in chunk.items() if k not in ("type", "node")}
print(f"\n ▸ {chunk['node']} {info}")
elif "__interrupt__" in chunk:
req = chunk["__interrupt__"][0].value["request"]
print(f"\n ⏸ 退款审批:{req['id']} ¥{req['amount_cny']:.2f} 规则={req['rule']}")
def main() -> None:
tickets = [json.loads(x) for x in DEMO.read_text(encoding="utf-8").splitlines() if x.strip()]
if len(sys.argv) > 1:
tickets = [tickets[int(sys.argv[1]) - 1]]
conn = connect()
graph = build_graph(Deps.from_conn(conn), checkpointer=InMemorySaver())
for t in tickets:
tid = biz.create_ticket(conn, t["customer_email"], t["subject"], t["body"])
cfg = {"configurable": {"thread_id": tid}}
print(f"\n===== {tid} · {t['customer_email']} · {t['subject']} =====")
show(graph, {"ticket": {"id": tid, **t}}, cfg)
while graph.get_state(cfg).next: # 停在审批节点
ok = input("\n 通过这笔退款吗?[y/N] ").strip().lower() == "y"
show(graph, Command(resume={"approved": ok, "approver": "cli", "note": "" if ok else "演示驳回"}), cfg)
final = graph.get_state(cfg).values
print(f"\n----- 最终回复({final.get('outcome')},¥{final.get('cost_cny', 0):.4f})-----\n{final.get('reply')}")
if __name__ == "__main__":
main()
"""端到端跑 data/seed/demo_tickets.jsonl实测把 10 张全跑了一遍,两次审批依次输入 y、n,10 张合计约 0.17 元(开着思考时约 0.26 元)。DEMO = Path(__file__).resolve().parent.parent / "data" / "seed" / "demo_tickets.jsonl"脚本在 scripts/ 下,往上两级是项目根。def show(graph, payload, cfg) -> None:跑图并把过程打印出来,第一次启动和审批后恢复都用它。for mode, chunk in graph.stream(payload, cfg, stream_mode=["updates", "custom"]):stream_mode 传列表时,每次产出一个 (模式, 数据) 二元组。custom 是节点里_emit推的事件,updates 是节点返回的状态更新。print(chunk["text"], end="", flush=True)token 不换行连着打,flush=True让字立刻出现在终端,不等缓冲区满。print(f"\n 🔧 {chunk['name']}({chunk['args']})实测打印🔧 check_refund_eligibility({"invoice_id": "INV-202609-0002"})。info = {k: v for k, v in chunk.items() if k not in ("type", "node")}字典推导式,去掉两个固定键,剩下的原样打印。实测▸ retrieve {'docs': ['KB-invoice-001', 'KB-invoice-002', ...]}。elif "__interrupt__" in chunk:updates 模式下,图停下时会产出一个带__interrupt__键的更新。print(f"\n ⏸ 退款审批:{req['id']}实测⏸ 退款审批:RF-T000153-INV-202609-0002 ¥950.00 规则=annual_first_14_days。金额同样用:.2f。tickets = [json.loads(x) for x in DEMO.read_text(encoding="utf-8").splitlines() if x.strip()]一行一个 JSON,if x.strip()跳过空行。tickets = [tickets[int(sys.argv[1]) - 1]]命令行写 3 就取下标 2,也就是第 3 张。graph = build_graph(Deps.from_conn(conn), checkpointer=InMemorySaver())真模型、真数据库、内存存档。tid = biz.create_ticket(conn, t["customer_email"], t["subject"], t["body"])先在库里建工单拿到编号。实测 seed 造了 152 张,第一张演示工单就是 T000153。while graph.get_state(cfg).next:next是接下来要跑的节点。图跑完是空元组,停在审批前是('approval',)。ok = input("\n 通过这笔退款吗?[y/N] ").strip().lower() == "y"只有输入 y 才通过,直接回车算驳回。[y/N]里大写的 N 表示默认值。final = graph.get_state(cfg).values取最终状态。print(f"\n----- 最终回复({final.get('outcome')}实测 10 张的结果和花费:
1 专票 resolved ¥0.0052;2 E2001 resolved ¥0.0052;
3 李娜退款(y) resolved ¥0.0241;
4 先问问 escalated ¥0.0200,没建申请,但被 PROMISE_RE 误伤;
5 王芳按比例退 498.75 元(n) resolved ¥0.0252;
6 重复扣款 39 元 escalated ¥0.0357,申请建了,但草稿两次没过质检;
7 月付 resolved ¥0.0199,解释不退;
8 账套只读 resolved ¥0.0210;
9 找不到客户 resolved ¥0.0101,请客户提供注册邮箱;
10 投诉 escalated ¥0.0017。
uv run python scripts/run_tickets.py 3 # 只跑第 3 张:李娜要退款
输出大致长这样(工单号、检索到的文章、模型的措辞每次会有差异):
===== T000153 · lina@example.com · 想退款 =====
▸ load_customer {'found': True}
▸ classify {'category': 'refund'}
▸ retrieve {'docs': ['KB-refund-002', 'KB-refund-001', ...]}
🔧 list_invoices({"customer_id": "C0002"})
🔧 check_refund_eligibility({"invoice_id": "INV-202609-0002"})
🔧 create_refund_request({"invoice_id": "INV-202609-0002", "reason": "..."})
[notify:dry-run]
【退款待审批】RF-T000153-INV-202609-0002
...
您好,您的专业版年付订阅是 9 月 24 日首次购买的,目前在 14 天内,符合全额退款条件 [KB-refund-002]。我们已为您提交退款申请……
▸ check {'passed': True, 'problems': []}
⏸ 退款审批:RF-T000153-INV-202609-0002 ¥950.00 规则=annual_first_14_days
通过这笔退款吗?[y/N] y
----- 最终回复(resolved,¥0.03 左右)-----
您好,……符合全额退款条件 [1]。……
参考文章:
[1] 年付 14 天无理由退款 https://help.cloudledger.example/help/refund/002
补充:您的退款申请已审核通过,950.00 元将原路退回,到账时间以支付渠道为准。
跑全部 10 张,每张都对照预期看一遍:第 4 张(只是问问)不应该建申请,第 7 张(月付)应该解释不退并建议关闭自动续费,第 9 张(找不到客户)应该请客户提供注册邮箱,第 10 张(投诉)应该直接转人工。
你会发现有些工单处理得不完美。比如”只是问问”那张,模型可能还是建了申请;或者某个回复漏了关键数字。别急着改,把问题记下来。Day 50 会把这些变成评测用例,Day 51 系统地修。今天的目标是”整条流程通了”,不是”每张都完美”。
动手练习
步骤 1:放文件。 config.py 的 Settings 加上 llm_extra_body,.env 里填好 LLM_EXTRA_BODY;src/supportpilot/llm.py,agent/ 下 state.py、prompts.py、deps.py、nodes.py、graph.py,scripts/run_tickets.py,data/seed/demo_tickets.jsonl,tests/ 下 fakes.py、test_graph_smoke.py、test_llm.py。
步骤 2:跑单测。 make test,确认新加的测试全过。这一步不需要 API key。
步骤 3:画出你的图。 LangGraph 能把编译好的图画成 Mermaid:
from supportpilot.agent.graph import build_graph
from fakes import FakeLLM, FakeTools, make_deps # 在 tests 目录下运行
g = build_graph(make_deps(FakeLLM({}, [], ""), FakeTools()))
print(g.get_graph().draw_mermaid())
from fakes import FakeLLM, FakeTools, make_deps用假零件建图,画图不需要数据库和 API key。g = build_graph(make_deps(FakeLLM({}, [], ""), FakeTools()))画图只看结构,FakeLLM 的参数随便给空的就行。print(g.get_graph().draw_mermaid())实测输出开头是graph TD;,接着列出__start__、九个节点和__end__,普通边是-->(如act --> draft;),条件边是虚线-.->(如check -.-> approval;)。
把输出贴到 mermaid.live 里看,和上面的图对照。
步骤 4:端到端。 确认 .env 里填了 LLM_API_KEY 和 EMB_API_KEY,数据库起着、数据导好了,然后 uv run python scripts/run_tickets.py,10 张全跑一遍。审批时第 3 张选 y,第 5 张选 n,看两种补充说明。
步骤 5:记一份问题清单。 新建 docs/day47-observations.md,每张工单一行:工单、期望、实际、哪里不对。至少记下 3 个问题,这是 Day 51 错误分析的原材料。
验收标准: make test 全绿(不需要 API);10 张演示工单都能跑完不崩;退款工单会停下等审批,批完能看到补充说明;投诉工单直接转人工;每张工单打印出了成本,大约在几分钱以内。
名词解释
| 名词 | 意思 |
|---|---|
| 成本账本 | LLMClient 上累计 token 用量的计数器,用来算每一步花了多少钱 |
| JSON mode | 让模型保证输出合法 JSON 的接口选项,不保证字段正确 |
| reducer | LangGraph 状态字段的合并方式,默认覆盖,operator.add 表示追加/累加 |
| 依赖注入 | 把外部依赖从参数传进来,而不是在函数里自己创建,方便替换成假的 |
| interrupt | LangGraph 中让图暂停并把数据交给外部的函数 |
| Command(resume) | 恢复暂停的图,并把值作为 interrupt() 的返回值 |
| checkpointer | 保存图状态的组件,暂停和恢复都依赖它 |
| evaluator-optimizer | 起草和质检分开,质检不过就带着意见重写 |
| for … else | Python 语法,循环没有被 break 时执行 else 块 |
常见误区
整个工单丢给一个大 agent。 能跑,但分诊错了、检索漏了、承诺乱说,你分不清是哪一步的问题,也没法单独优化。拆成节点,每个节点有明确的输入输出。
interrupt 之前写副作用。 恢复时会再执行一次。写库、发通知、调外部接口,都放在 interrupt 之后或者别的节点。
质检全交给模型。 能用规则查的(编号是否存在、是否有违禁承诺)先用规则,确定、免费、不会漏。模型质检只做规则做不了的判断。
单测里调真的大模型。 慢、花钱、结果不确定,测试会时好时坏。工作流逻辑用假 LLM 测,模型能力用评测测,两件事分开。
流式 usage 每块都入账。 成本会被夸大很多倍,而且很难察觉,直到月底看账单。
一看到不完美的回复就改提示词。 今天改一句、明天改一句,改了什么、变好还是变坏都说不清。先记下来,等有了评测集再系统地改。
学习资料
文档与文章
- LangGraph 概览 和 Graph API:State、reducer、条件边的官方说明,1 小时。
- LangGraph Interrupts:重点读”节点恢复时从头重跑”和”interrupt 前的副作用要幂等”两段,20 分钟。
- LangGraph Streaming:
stream_mode的几种模式,特别是custom模式和get_stream_writer,20 分钟。 - DeepSeek JSON Output 和 Function Calling:你用的厂商的结构化输出和工具调用说明,换厂商就看对应文档,20 分钟。
- Anthropic · Building effective agents:重读 evaluator-optimizer 那一节,10 分钟。
B 站视频
- 上面嵌的敲代码的码农《挑战 19 分钟搞定,LangGraph 快速入门与原理剖析》。
- 尚硅谷《LangGraph 教程》(BV1z3NY66EY1,132 集合集):系统性最强的中文 LangGraph 课,今天用到的条件边、checkpointer、人工介入都有专门的集,按目录挑着看,别从头刷。
其他经典视频
- 上面嵌的 LangChain《LangGraph interrupt》。
- LangChain《Build a Customer Support Bot | LangGraph》(YouTube,b3XsvoFWp4c):航空客服机器人,对比它和 SupportPilot 的设计差异:它的敏感操作是怎么确认的、节点是怎么分的。
- LangChain Academy · Introduction to LangGraph:官方免费课,Module 3(人机交互)和 Module 4(子图、并行)和今天最相关。
书籍
- Chip Huyen《AI Engineering》第 6 章 Agents 部分的”Planning”和”Agent Failure Modes and Evaluation”:规划、工具选择错误、目标偏离等失败模式,对照我们为什么用固定流程,约 1 小时。
- Jay Alammar、Maarten Grootendorst《Hands-On Large Language Models》第 7 章 Advanced Text Generation Techniques and Tools:后半部分讲 Agent 和 ReAct,图很清楚,40 分钟。
开源项目与代码
- langchain-ai/langgraph:
libs/langgraph/langgraph/types.py里看interrupt和Command的实现,理解为什么恢复时会重跑节点。 - sierra-research/tau-bench:看它的 agent 是怎么调工具的,和我们的 act 节点对比。
自测
1. tool_log 和 cost_cny 为什么要用 Annotated[..., operator.add]?去掉会怎样?
答案
这两个字段需要累加而不是覆盖。tool_log 记录整张工单所有的工具调用,cost_cny 是各节点花费之和。去掉的话,每个节点返回的值会覆盖之前的,最后只剩最后一个节点的花费,工具记录也只剩最后一批。
2. 质检第一次不通过、第二次也不通过,工作流会怎么走?revisions 分别是多少?
答案
第一次不通过,revisions 从 0 变成 1,1 <= max_revisions(1),回到 draft 重写;第二次不通过,revisions 变成 2,2 > 1,走 handoff 转人工。
3. 审批节点里,如果把”写一条日志到数据库”放在 interrupt() 之前,会发生什么?
答案
会写两次。图第一次执行到审批节点时写一次,然后在 interrupt 处暂停;人做出决定后图恢复,审批节点从头重跑,又写一次。interrupt 之前的代码必须没有副作用或者幂等。
4. 为什么 check 节点先跑规则,规则不过就不调模型了?
答案
规则检查确定、免费、不会漏(编号是否存在、是否有违禁承诺);规则发现问题时草稿反正要重写,再花钱让模型检查没有意义。规则过了,才需要模型做规则做不了的判断(事实是否都有依据)。
5. 「面试题」你的工作流怎么测试?大模型的输出不确定,怎么写单测?
答案
分两层。工作流逻辑(路由、审批暂停恢复、重写次数、转人工条件)用假 LLM 测:通过依赖注入把假的大模型、检索、工具传进节点,按剧本返回固定结果,测试确定、免费、几秒钟跑完。模型能力(分类准不准、回复好不好)不在单测里测,用评测集跑真实模型,看统计指标。两者分开,单测保证”流程对”,评测衡量”效果好”。
6. 「面试题」LangGraph 的 interrupt 是怎么实现暂停和恢复的?需要什么前提?
答案
interrupt() 内部抛一个特殊异常让图停下,当前状态由 checkpointer 按 thread_id 保存;外部用同一个 thread_id 调 invoke(Command(resume=值)) 时,LangGraph 读回状态,从被中断的节点开头重新执行,这次 interrupt() 直接返回 resume 的值。前提是编译时配了 checkpointer,恢复时用同一个 thread_id;因为节点会重跑,interrupt 之前的代码要没有副作用,也不能用 try/except 把 interrupt 包住吞掉异常。
明天预告
Day 48 · 多轮与记忆:会话状态、客户画像、上下文压缩。今天的工作流只能处理”一问一答”,客户追问一句它就忘了前面说过什么。明天加上三种记忆:同一张工单内的多轮对话(状态存进 Postgres,进程重启也不丢)、跨工单的客户画像(吴刚第四次来问银行同步,Agent 应该知道他之前遇到过三次)、对话太长时的自动压缩。
评论