阶段4(上):LangGraph、Function Calling、MCP(SDK 2.x)、Agent 评测与运维、Agent 设计专题;run_examples 设置 RUN_EXAMPLES=1
This commit is contained in:
1 parent
6650f7eb6d
commit
caaec830b3
38 files changed
+3515
-64
No files matched your search
@@ -0,0 +1,137 @@
|
||||
# requires: pydantic
|
||||
"""手写工具调用循环:工具定义(JSON Schema)→ 模型决定调用 → 校验参数 → 执行 → 结果回传 → 直到模型给出答案。
|
||||
|
||||
为了离线运行,模型用 ScriptedModel 模拟,它返回的消息格式和 OpenAI Chat Completions 的 tool_calls 一致;
|
||||
换成真实模型只需要把 ScriptedModel.chat 换成 client.chat.completions.create(见文件末尾注释)。
|
||||
运行:python tool_loop.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable
|
||||
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
|
||||
|
||||
# ---------- 工具:用 pydantic 定义参数,自动生成 JSON Schema ----------
|
||||
class GetOrderArgs(BaseModel):
|
||||
order_id: str = Field(pattern=r"^A\d+$", description="订单号,例如 A1001")
|
||||
|
||||
|
||||
class RefundArgs(BaseModel):
|
||||
order_id: str = Field(pattern=r"^A\d+$")
|
||||
amount: float = Field(gt=0, le=10000, description="退款金额(元)")
|
||||
|
||||
|
||||
ORDERS = {"A1001": {"user": "u1", "paid": 199.0, "status": "delivered"}}
|
||||
REFUNDS: dict[str, float] = {}
|
||||
|
||||
|
||||
def get_order(args: GetOrderArgs, ctx: dict) -> dict:
|
||||
order = ORDERS.get(args.order_id)
|
||||
if not order or order["user"] != ctx["user_id"]: # 权限在工具里校验,不依赖模型
|
||||
return {"error": "订单不存在"}
|
||||
return {"order_id": args.order_id, **order}
|
||||
|
||||
|
||||
def refund(args: RefundArgs, ctx: dict) -> dict:
|
||||
order = ORDERS.get(args.order_id)
|
||||
if not order or order["user"] != ctx["user_id"]:
|
||||
return {"error": "订单不存在"}
|
||||
if args.amount > order["paid"]:
|
||||
return {"error": f"退款金额不能超过实付 {order['paid']}"}
|
||||
REFUNDS.setdefault(args.order_id, args.amount) # 幂等:同一订单只退一次
|
||||
return {"ok": True, "refunded": REFUNDS[args.order_id]}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Tool:
|
||||
name: str
|
||||
description: str
|
||||
args_model: type[BaseModel]
|
||||
fn: Callable[[Any, dict], dict]
|
||||
needs_confirm: bool = False # 有副作用的工具需要用户确认
|
||||
|
||||
def schema(self) -> dict:
|
||||
return {"type": "function", "function": {
|
||||
"name": self.name, "description": self.description,
|
||||
"parameters": self.args_model.model_json_schema()}}
|
||||
|
||||
|
||||
TOOLS = {t.name: t for t in [
|
||||
Tool("get_order", "查询当前用户的订单详情", GetOrderArgs, get_order),
|
||||
Tool("refund", "为订单发起退款,金额不能超过实付金额", RefundArgs, refund, needs_confirm=True),
|
||||
]}
|
||||
|
||||
|
||||
# ---------- 模拟模型:按脚本依次返回 assistant 消息 ----------
|
||||
@dataclass
|
||||
class ScriptedModel:
|
||||
script: list[dict]
|
||||
seen: list[list[dict]] = field(default_factory=list)
|
||||
|
||||
def chat(self, messages: list[dict], tools: list[dict]) -> dict:
|
||||
self.seen.append([dict(m) for m in messages])
|
||||
return self.script.pop(0)
|
||||
|
||||
|
||||
def call(id_: str, name: str, args: dict | str) -> dict:
|
||||
return {"id": id_, "type": "function",
|
||||
"function": {"name": name, "arguments": args if isinstance(args, str) else json.dumps(args)}}
|
||||
|
||||
|
||||
# ---------- 循环本体 ----------
|
||||
def run_agent(model, user_msg: str, ctx: dict, confirm: Callable[[str, dict], bool], max_steps: int = 6) -> dict:
|
||||
messages: list[dict] = [
|
||||
{"role": "system", "content": "你是售后助手。只能通过工具查询和操作订单;信息不足时先询问用户。"},
|
||||
{"role": "user", "content": user_msg},
|
||||
]
|
||||
trace = []
|
||||
for step in range(max_steps):
|
||||
msg = model.chat(messages, [t.schema() for t in TOOLS.values()])
|
||||
messages.append(msg)
|
||||
calls = msg.get("tool_calls") or []
|
||||
if not calls:
|
||||
return {"answer": msg["content"], "steps": step + 1, "trace": trace}
|
||||
for c in calls: # 一次可能返回多个工具调用
|
||||
name, raw = c["function"]["name"], c["function"]["arguments"]
|
||||
tool = TOOLS.get(name)
|
||||
try:
|
||||
if tool is None:
|
||||
raise KeyError(f"未知工具 {name}")
|
||||
args = tool.args_model.model_validate_json(raw) # 参数校验:模型给的参数不可信
|
||||
if tool.needs_confirm and not confirm(name, args.model_dump()):
|
||||
result = {"error": "用户拒绝执行该操作"}
|
||||
else:
|
||||
result = tool.fn(args, ctx)
|
||||
except (ValidationError, KeyError, json.JSONDecodeError) as e:
|
||||
result = {"error": f"参数错误:{str(e)[:200]}"} # 错误也作为工具结果返回,让模型自我修正
|
||||
trace.append((name, raw, result))
|
||||
messages.append({"role": "tool", "tool_call_id": c["id"],
|
||||
"content": json.dumps(result, ensure_ascii=False)})
|
||||
return {"answer": "步骤过多,已转人工处理。", "steps": max_steps, "trace": trace}
|
||||
|
||||
|
||||
def demo_script() -> list[dict]:
|
||||
return [
|
||||
{"role": "assistant", "content": None, "tool_calls": [call("c1", "get_order", {"order_id": "1001"})]},
|
||||
{"role": "assistant", "content": None, "tool_calls": [call("c2", "get_order", {"order_id": "A1001"})]},
|
||||
{"role": "assistant", "content": None, "tool_calls": [call("c3", "refund", {"order_id": "A1001", "amount": 199})]},
|
||||
{"role": "assistant", "content": "已为订单 A1001 退款 199 元。"},
|
||||
]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
model = ScriptedModel(demo_script())
|
||||
out = run_agent(model, "订单 1001 我要退款", {"user_id": "u1"}, confirm=lambda n, a: True)
|
||||
for name, raw, result in out["trace"]:
|
||||
print(f"调用 {name}({raw}) -> {result}")
|
||||
print("回答:", out["answer"], "步数:", out["steps"])
|
||||
assert "参数错误" in out["trace"][0][2]["error"] and REFUNDS == {"A1001": 199.0}
|
||||
|
||||
# 换成真实模型(OpenAI 兼容接口,参数以官方文档为准):
|
||||
# from openai import OpenAI
|
||||
# client = OpenAI()
|
||||
# resp = client.chat.completions.create(model="...", messages=messages, tools=schemas)
|
||||
# msg = resp.choices[0].message.model_dump(exclude_none=True)
|
||||
Reference in new issue
Block a user