用 LangChain 1.x 的
create_agent做一个客服退款助手:
get_order只读,自动执行;refund会修改数据,由HumanInTheLoopMiddleware拦下,人工批准或拒绝之后才继续;refund内部还会再做一次业务校验(金额、状态),批准了也不能超额退款;ToolCallLimitMiddleware限制单次运行的工具调用次数。
先看清成品(box 上真实运行,订单数据是虚构的)
- qwen3 0.6B:A1001 退 199 元时暂停等待审批,批准后执行,审计记录
[('A1001', 199.0, '耳机杂音')];拒绝时模型收到“User rejected...”,审计记录为空;A1002(运输中)由模型查询后直接告知不能退款。- qwen2.5 1.5B:在这个任务上失败了,两种失败情况都在调试示例里如实记录。
- pytest 5 项通过(用脚本化的测试替身,不需要 Ollama)。
合规提醒:本系列只用本地 Ollama 模型,数据不出本机。如果换成云端模型,请先确认数据合规和服务商条款,API Key 放在环境变量里,不要共享账号。模型的许可证以它在 Ollama 或 Hugging Face 页面上写的为准。示例数据都是我自己编的。本文不列价格。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
agent.py |
智能体、两个工具、中间件,以及命令行审批流程 |
test_agent.py |
5 项测试:暂停后批准、拒绝、批准后仍被业务校验拦下、运输中、命令行流程 |
需要准备哪些环境?
| 项目 | box 上的版本 |
|---|---|
| Python | 3.13 |
| langchain / langchain-core | 1.4.6 / 1.6.12 |
| langchain-ollama | 1.1.0 |
| langgraph | 1.2.16 |
| Ollama | 0.40.7(只用 CPU) |
| 模型 | box 上导入为 qwen2.5-local:1.5b、qwen3-local:0.6b、qwen3-embedding-local:0.6b;对应的官方标签是 qwen2.5:1.5b、qwen3:0.6b、qwen3-embedding:0.6b(用 ollama pull 拉取,再通过环境变量 OLLAMA_MODEL / EMBED_MODEL 指定) |
安装:pip install -r requirements.txt
关键 API(来自 langchain 1.4.6 源码和文档)
| API | 说明 |
|---|---|
create_agent(model, tools, system_prompt=, middleware=, checkpointer=) |
返回 LangGraph 编译后的图 |
HumanInTheLoopMiddleware(interrupt_on={"refund": True}) |
默认是 batched 模式:每轮模型输出只中断一次,action_requests 列出所有需要审批的调用 |
| 恢复执行 | Command(resume={"decisions": [{"type": "approve"}]}),决定类型有 approve、edit、reject、respond |
| 需要 checkpointer | 中断依赖 LangGraph 保存的状态,示例里用 InMemorySaver |
ToolCallLimitMiddleware(run_limit=6) |
限制单次运行的工具调用次数 |
文件结构
60-langchain-C-src/
├── agent.py
├── requirements.txt
└── test_agent.py
第 1 步:智能体
"""客服退款助手:LangChain create_agent + 人工审批。
- get_order 只读,自动执行;refund 会改数据,由 HumanInTheLoopMiddleware 拦下,必须人工批准、修改或拒绝
- refund 内部还会再校验一次(金额不超过实付、状态允许),不把安全寄托在模型或审批人身上
- ToolCallLimitMiddleware 限制单次运行的工具调用次数,防止小模型陷入循环
订单数据是教程虚构的。"""
import os
from langchain.agents import create_agent
from langchain.agents.middleware import HumanInTheLoopMiddleware, ToolCallLimitMiddleware
from langchain_core.tools import tool
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import Command
MODEL = os.environ.get("OLLAMA_MODEL", "qwen3-local:0.6b") # 实测 qwen2.5 1.5B 在本例里不调用 refund,见教程
ORDERS = {
"A1001": {"item": "蓝牙耳机", "paid": 199.0, "status": "已签收", "refunded": 0.0},
"A1002": {"item": "机械键盘", "paid": 459.0, "status": "运输中", "refunded": 0.0},
}
AUDIT = []
@tool
def get_order(order_id: str) -> dict:
"""查询订单:商品、实付金额、状态、已退金额。order_id 形如 A1001。"""
o = ORDERS.get(order_id.strip().upper())
return {"order_id": order_id, **o} if o else {"error": f"订单 {order_id} 不存在"}
@tool
def refund(order_id: str, amount: float, reason: str) -> str:
"""给订单退款。只有已签收的订单可以退,累计退款不能超过实付金额。"""
o = ORDERS.get(order_id.strip().upper())
if not o:
return f"失败:订单 {order_id} 不存在"
if o["status"] != "已签收":
return f"失败:订单状态为 {o['status']},不能退款"
if amount <= 0 or o["refunded"] + amount > o["paid"]:
return f"失败:可退金额为 {o['paid'] - o['refunded']:.2f}"
o["refunded"] += amount
AUDIT.append((order_id, amount, reason))
return f"成功:订单 {order_id} 已退款 {amount:.2f} 元"
SYSTEM = ("你是网店客服。先用 get_order 查订单,再决定是否调用 refund。"
"“已签收”的订单可以退款,“运输中”的不能退。退款金额不能超过实付金额。回答用中文,简短。")
def build(model=None):
if model is None:
from langchain_ollama import ChatOllama
model = ChatOllama(model=MODEL, temperature=0)
return create_agent(model, tools=[get_order, refund], system_prompt=SYSTEM,
middleware=[HumanInTheLoopMiddleware(interrupt_on={"refund": True, "get_order": False}),
ToolCallLimitMiddleware(run_limit=6)],
checkpointer=InMemorySaver())
def pending(agent, config):
"""返回待审批的工具调用列表(没有则为空)。"""
st = agent.get_state(config)
return [a for it in st.interrupts for a in it.value["action_requests"]]
def run_cli(text, thread="demo", decide=input):
agent, cfg = build(), {"configurable": {"thread_id": thread}}
agent.invoke({"messages": [{"role": "user", "content": text}]}, cfg)
while reqs := pending(agent, cfg):
decisions = []
for r in reqs:
ans = decide(f"批准 {r['name']}({r['args']})?[y=批准 / n=拒绝] ").strip().lower()
decisions.append({"type": "approve"} if ans == "y" else {"type": "reject", "message": "审批人拒绝了这次退款"})
agent.invoke(Command(resume={"decisions": decisions}), cfg)
msgs = agent.get_state(cfg).values["messages"]
for m in msgs:
calls = getattr(m, "tool_calls", None)
print(f"[{m.type}]", (f"调用 {[(c['name'], c['args']) for c in calls]}" if calls else str(m.content)[:200]))
return msgs
if __name__ == "__main__":
import sys
run_cli(" ".join(sys.argv[1:]) or "订单 A1001 的耳机有杂音,我要退款 199 元")
print("审计记录:", AUDIT)
第 2 步:测试
import pytest
from langchain_core.language_models.fake_chat_models import GenericFakeChatModel
from langchain_core.messages import AIMessage
from langgraph.types import Command
import agent as A
class ScriptedModel(GenericFakeChatModel):
"""按顺序吐出预设消息(可带 tool_calls);bind_tools 原样返回自己。"""
def bind_tools(self, tools, **kw):
return self
def call(name, args, i):
return AIMessage(content="", tool_calls=[{"name": name, "args": args, "id": f"c{i}", "type": "tool_call"}])
@pytest.fixture(autouse=True)
def reset():
A.ORDERS["A1001"]["refunded"] = 0.0; A.AUDIT.clear()
ORIG_BUILD = A.build # 测试里会 monkeypatch A.build,先保存原函数
def make(script):
return ORIG_BUILD(ScriptedModel(messages=iter(script)))
CFG = {"configurable": {"thread_id": "t"}}
REFUND_FLOW = lambda amt: [call("get_order", {"order_id": "A1001"}, 1),
call("refund", {"order_id": "A1001", "amount": amt, "reason": "杂音"}, 2),
AIMessage(content="已处理")]
def test_refund_pauses_for_approval_then_runs():
ag = make(REFUND_FLOW(199))
ag.invoke({"messages": [{"role": "user", "content": "退款"}]}, CFG)
reqs = A.pending(ag, CFG)
assert [r["name"] for r in reqs] == ["refund"] and A.AUDIT == [] # get_order 已自动执行,refund 被拦下
ag.invoke(Command(resume={"decisions": [{"type": "approve"}]}), CFG)
assert A.AUDIT == [("A1001", 199, "杂音")] and A.pending(ag, CFG) == []
def test_reject_does_not_refund():
ag = make(REFUND_FLOW(199))
ag.invoke({"messages": [{"role": "user", "content": "退款"}]}, CFG)
ag.invoke(Command(resume={"decisions": [{"type": "reject", "message": "不批"}]}), CFG)
msgs = ag.get_state(CFG).values["messages"]
assert A.AUDIT == [] and any("不批" in str(m.content) for m in msgs if m.type == "tool")
def test_tool_guard_even_if_approved():
ag = make(REFUND_FLOW(500))
ag.invoke({"messages": [{"role": "user", "content": "退款"}]}, CFG)
ag.invoke(Command(resume={"decisions": [{"type": "approve"}]}), CFG)
tool_out = [m.content for m in ag.get_state(CFG).values["messages"] if m.type == "tool"][-1]
assert "可退金额为 199.00" in tool_out and A.AUDIT == []
def test_in_transit_blocked():
assert "运输中" in A.refund.invoke({"order_id": "A1002", "amount": 1, "reason": "x"})
def test_cli_with_scripted_decisions(monkeypatch, capsys):
monkeypatch.setattr(A, "build", lambda: make(REFUND_FLOW(50)))
A.run_cli("退款", decide=lambda prompt: "y")
assert A.AUDIT == [("A1001", 50, "杂音")] and "已处理" in capsys.readouterr().out
第 3 步:运行
$ ollama pull qwen3:0.6b && export OLLAMA_MODEL=qwen3:0.6b # box 上是 qwen3-local:0.6b
$ python agent.py # 输入 y
批准 refund({'order_id': 'A1001', 'amount': 199, 'reason': '耳机杂音'})?[y=批准 / n=拒绝] y
[human] 订单 A1001 的耳机有杂音,我要退款 199 元
[ai] 调用 [('refund', {'order_id': 'A1001', 'amount': 199, 'reason': '耳机杂音'})]
[tool] 成功:订单 A1001 已退款 199.00 元
[ai] 成功:订单 A1001 已退款 199.00 元。需要其他帮助吗?
审计记录: [('A1001', 199.0, '耳机杂音')]
$ python agent.py # 输入 n
[tool] User rejected the tool call for `refund` with reason: 审批人拒绝了这次退款
[ai] 订单A1001的退款已拒绝,原因可能是审批人未通过。...
审计记录: []
$ python agent.py 订单 A1002 的键盘我不要了,退 459 元
[ai] 调用 [('get_order', {'order_id': 'A1002'})]
[tool] {"order_id": "A1002", "item": "机械键盘", "paid": 459.0, "status": "运输中", "refunded": 0.0}
[ai] 订单A1002的运输中状态,无法申请退款。...
$ python -m pytest -q
5 passed
注意第一段:qwen3 0.6B 跳过了 get_order,直接调用了 refund。提示词要求先查询,但模型没有照做。这正是 refund 内部必须自己校验的原因。
调试示例
1. qwen2.5 1.5B 把规则理解反了。 用最初的提示词时,模型查到 A1001 的状态是“已签收”,却回答“目前订单已签收,无法退款”,根本没有调用 refund。我在提示词里明确写出“‘已签收’的订单可以退款”之后,它仍然回答“订单 A1001 的耳机已签收,无法退款。”
2. 提示词写长之后,小模型把工具调用写成了普通文本。 我试过把规则写成 1/2/3 步骤的长提示词,结果 qwen2.5 1.5B 连续 4 次都输出了 {{"name": "get_order", "arguments": ...}} 这样的文本,而不是真正的 tool_calls,所以没有任何工具被执行,审计记录也为空(从安全角度看,这算是“安全地失败”)。最后我把默认模型改成在这个任务上能正确调用工具的 qwen3 0.6B,并把这两种失败都写进了教程。
3. 测试里的 monkeypatch 套娃。 test_cli_with_scripted_decisions 把 A.build 替换成 lambda: make(...),而 make 内部又调用 A.build(...),于是报错 <lambda>() takes 0 positional arguments but 1 was given。在模块加载时先保存 ORIG_BUILD = A.build 就解决了。
常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
| 恢复执行时报错,或者找不到中断 | 没有配置 checkpointer,或者 thread_id 变了 | 用同一个 thread_id,并配置 InMemorySaver 等 checkpointer |
| 批准后仍然超额退款 | 工具内部没有校验 | 在 refund 内部校验金额和状态 |
| 模型不调用工具 | 小模型的工具调用能力弱 | 换模型并用真实任务验证;写不出 tool_calls 时视为失败 |
| 模型反复调用工具 | 陷入循环 | 用 ToolCallLimitMiddleware 设置上限 |
模板规则
- 会修改数据的工具一律经过人工审批。
- 工具内部做最终校验。
- 记录审计日志。
- 换模型前,先用真实任务跑一遍批准、拒绝、拦截三条路径。
项目资源
| 用途 | 地址 |
|---|---|
| LangChain 文档 | https://docs.langchain.com/oss/python/langchain/agents |
| ChatOllama 集成 | https://docs.langchain.com/oss/python/integrations/chat/ollama |
| LangChain 源码 | https://github.com/langchain-ai/langchain |
| Human-in-the-loop | https://docs.langchain.com/oss/python/langchain/human-in-the-loop |
| 中间件 | https://docs.langchain.com/oss/python/langchain/middleware |
| qwen3(Ollama) | https://ollama.com/library/qwen3 |
智能体可以决定“想做什么”,但真正改数据之前,要经过人工审批和工具内部的校验。