从零开始构建售后智能体:从工具注册到上线评测
- 从零开始构建售后智能体:从工具注册到上线评测
从零开始构建售后智能体:从工具注册到上线评测
📚 系列文章导航
本系列围绕企业知识助手、售后智能体和仓储视觉识别,介绍从数据准备、模型开发到部署评测的实践流程,并提供配套排障指南。有修订版的文章,建议优先阅读修订版。
一、企业知识助手
从业务资料整理开始,逐步完成问答数据构建、模型微调、权重合并、量化与本地部署。
👉 从零开始构建企业知识助手:业务资料整理、模型微调与本地部署(修订版)
二、售后智能体
围绕售后业务,学习工具注册、智能体构建以及上线评测。
三、仓储视觉识别
从图像标注开始,逐步完成视觉模型训练、导出与部署。
👉 从零开始构建仓储视觉识别系统:从图像标注到模型部署(修订版)
四、模型开发排障
遇到环境、依赖、训练、推理、量化或部署问题时,可以按故障所在环节查阅。
👉 模型开发实用排障指南:从 Python(编程语言)环境到训练、推理与部署(修订版)
历史版本
电商平台希望把售后咨询交给一个能自己动手的助理:用户报出订单号就能查到状态,问"到哪了"就能查出物流,问"能不能退"就能引用政策条款,需要退款时先复述再执行,情绪激烈时转人工。我们要围绕这个业务,完成工具设计、参数校验、对话循环、记忆管理、知识检索、离线测试、效果评测、上线服务和线上排错。这是一份可以边做边查的学习记录,不要求先掌握算法原理;示例场景不附带真实业务数据,也不预设任何效果指标。
这里做的是Agent(智能体):模型不直接回答所有问题,而是先判断该不该调用工具、调用哪个工具、参数填什么,再根据工具返回的事实决定下一步。人先把工具能做什么、边界在哪里写清楚,再让模型在这些约束内组合动作;做完之后用另一批标注任务检查它到底做对没有,最后交给独立服务处理真实咨询。
文章先给出一条完整实践主线,再集中解释参数与可选做法,最后按现象排查问题。主线统一使用六个工具、一轮最多六步、只读默认、固定替身模型做离线回归、同步接口加队列汇总事件。示例数字只是让各段能衔接,不是普遍最优配置。
Python(编程语言)代码写进文件,终端命令在命令窗口执行,两者不要混贴。代码框上方会标明新建或追加的文件;同名文件按出现顺序拼接,保留缩进,不复制代码框边界。术语在文字中解释,代码标识符和文件名保持程序要求的原样。文中不依赖任何真实订单数据,也不展示真实模型返回。
第一部分:按顺序完成实践
1. 建立项目目录并准备运行环境
目标:准备一个能保存提示词、工具、数据、测试和记录的工程位置。
操作位置:本地 Linux 或 macOS 的 Bash(命令解释器);Windows 用户在 WSL2(Windows 下的 Linux 子系统)或 Git Bash 中执行同一套命令。
先修改:项目根目录/home/user/projects/shop_agent换成自己的可写路径;模型接入信息通过环境变量传入,不写进代码。
参数与原理见对应参考。下面按顺序操作。
智能体工程和普通脚本工程最大的区别是:提示词、工具定义、评测清单都是需要版本管理的资产。所以第一件事不是写循环,而是把目录分清楚,让每一类东西都有固定归处。
mkdir -p /home/user/projects/shop_agent
cd /home/user/projects/shop_agent
mkdir -p agent service data kb/policies tests reports
python3 -V
| 目录或文件 | 存什么 | 首次使用状态 |
|---|---|---|
agent/ |
参数校验、工具注册、对话循环、记忆、检索、日志 | 代码目录,可提交版本库 |
service/ |
对外的 HTTP 接口 | 只在部署时需要 |
data/ |
业务库和评测清单 | 业务库不入库版本管理 |
kb/policies/ |
售后政策文档 | 纯文本,必须可追溯来源 |
tests/ |
离线测试 | 每次改提示词都重跑 |
reports/ |
运行轨迹、评测报告、会话存档 | 按时间累积,不要覆盖 |
安装依赖时先固定版本,避免"昨天能跑今天不能跑"这类无法归因的问题。主线只依赖官方 SDK、评测用到的标准库和部署用的 Web 框架;测试部分完全不需要联网。
python3 -m pip install "openai==1.55.3" "fastapi==0.115.6" "uvicorn==0.34.0" "pydantic==2.10.4"
python3 -m pip check
python3 -c "import openai; print(openai.__version__)"
python3 -m pip freeze > reports/environment.txt
模型接入信息放在环境变量里,只对当前终端生效。不要把密钥写进代码、提示词或日志;换机器时重新导出,不要复制粘贴到聊天窗口。
export AGENT_API_KEY=实际密钥
export AGENT_BASE_URL=https://api.deepseek.com/v1
export AGENT_MODEL=deepseek-chat
AGENT_BASE_URL(接口地址)使用 OpenAI 兼容协议;本文所有调用只用该协议的chat.completions.create和tools字段,不依赖某家厂商的私有扩展。换成别家兼容服务时先看环境与接口分支,不要假定工具调用格式完全一致。
完成检查:目录齐全;pip check没有冲突;环境变量在当前终端可见(可用env | grep AGENT_确认,密钥内容不要贴到日志里)。
2. 把业务能力写成工具,并生成模型可读的清单
目标:让每个业务动作都有唯一名字、明确描述和严格参数格式。
操作位置:项目根目录,编辑agent/下的代码。
先修改:订单号、买家编号等字段名要和真实业务系统一致;本文用SO20241105001这类演示值。
参数与原理见对应参考。下面按顺序操作。
智能体的能力上限由工具清单决定:模型只能调用你声明过的工具,不能自创。所以"写提示词"之前必须先把工具写对。本文六个工具覆盖查询、检索和写入三类动作,先看它们各自解决什么问题。
| 工具名 | 解决什么问题 | 是否写操作 | 幂等 | 超时 | 失败后的策略 |
|---|---|---|---|---|---|
get_order |
按订单号查状态、金额、运单号 | 否 | 是 | 5 秒 | 返回found: false,提示核对订单号 |
list_orders |
用户没有订单号时按买家编号缩小范围 | 否 | 是 | 5 秒 | 返回空列表,不要编造订单 |
track_shipping |
查承运商、运单号和当前轨迹状态 | 否 | 是 | 5 秒 | 返回found: false,说明无法给出物流 |
query_policy |
检索售后政策条款并带来源编号 | 否 | 是 | 5 秒 | 未命中时提示转人工,不要凭记忆回答 |
create_refund |
创建退款工单 | 是 | 是(靠幂等键) | 5 秒 | 重复提交返回同一个工单号 |
handoff_to_human |
转人工并生成工单 | 是 | 是 | 5 秒 | 返回工单号,不再继续自动处理 |
建立业务库和演示数据
新建agent/store.py。数据库只用于演示工具如何取数,真实项目替换成内部服务即可,接口形状保持一致。
from __future__ import annotations
import sqlite3
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
DB_PATH = ROOT / "data" / "shop.db"
SCHEMA = """
DROP TABLE IF EXISTS refunds;
DROP TABLE IF EXISTS orders;
CREATE TABLE orders (
order_id TEXT PRIMARY KEY,
buyer_id TEXT NOT NULL,
status TEXT NOT NULL,
amount REAL NOT NULL,
carrier TEXT NOT NULL,
tracking TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE refunds (
refund_id TEXT PRIMARY KEY,
order_id TEXT NOT NULL,
reason TEXT NOT NULL,
state TEXT NOT NULL,
created_at TEXT NOT NULL
);
"""
ORDERS = [
("SO20241105001", "U1001", "已签收", 259.00, "顺丰", "SF1234567890", "2024-11-05"),
("SO20241106002", "U1001", "运输中", 88.50, "中通", "ZT9876543210", "2024-11-06"),
("SO20241107003", "U1002", "已签收", 1299.00, "京东", "JD5555666677", "2024-11-07"),
]
def connect() -> sqlite3.Connection:
connection = sqlite3.connect(DB_PATH)
connection.row_factory = sqlite3.Row
return connection
def init_db() -> None:
DB_PATH.parent.mkdir(exist_ok=True)
with connect() as connection:
connection.executescript(SCHEMA)
connection.executemany(
"INSERT OR REPLACE INTO orders VALUES (?,?,?,?,?,?,?)", ORDERS
)
if __name__ == "__main__":
init_db()
print(f"已初始化 {DB_PATH}")
python3 agent/store.py
让工具函数只返回事实,不返回话术
在agent/tools.py中新建以下内容。要点是工具函数只管取数和判规则,不负责组织中文回复;话术交给模型,事实交给工具,这样评测时才能分别判定。
from __future__ import annotations
import json
from pathlib import Path
from agent.registry import ToolRegistry, ToolSpec
from agent.store import connect
ROOT = Path(__file__).resolve().parent.parent
REFUND_WINDOW_DAYS = 7
MAX_REFUND_AMOUNT = 500.0
TEXT = {"type": "string"}
def _params(properties: dict, required: list[str]) -> dict:
return {
"type": "object",
"properties": properties,
"required": required,
"additionalProperties": False,
}
def get_order(order_id: str) -> dict:
with connect() as connection:
row = connection.execute(
"SELECT * FROM orders WHERE order_id = ?", (order_id,)
).fetchone()
if row is None:
return {"found": False, "hint": "订单号应为 SO 开头加 11 位数字,请与用户核对"}
return {"found": True, "found_order": dict(row)}
def list_orders(buyer_id: str, limit: int = 5) -> dict:
with connect() as connection:
rows = connection.execute(
"SELECT order_id, status, amount, created_at FROM orders"
" WHERE buyer_id = ? ORDER BY created_at DESC LIMIT ?",
(buyer_id, limit),
).fetchall()
return {"buyer_id": buyer_id, "count": len(rows), "orders": [dict(row) for row in rows]}
def track_shipping(order_id: str) -> dict:
with connect() as connection:
row = connection.execute(
"SELECT carrier, tracking, status FROM orders WHERE order_id = ?", (order_id,)
).fetchone()
if row is None:
return {"found": False, "hint": "查不到该订单,无法给出物流"}
return {"found": True, "carrier": row["carrier"], "tracking": row["tracking"],
"status": row["status"]}
def query_policy(question: str, top_k: int = 3) -> dict:
from agent.rag import retrieve
hits = retrieve(question, top_k=top_k)
return {"question": question,
"hits": [{"source": hit["source"], "score": hit["score"],
"text": hit["text"]} for hit in hits],
"hint": "回答必须带来源编号" if hits else "没有命中条款,请转人工"}
def create_refund(order_id: str, reason: str, idempotency_key: str,
amount: float | None = None) -> dict:
with connect() as connection:
order = connection.execute(
"SELECT * FROM orders WHERE order_id = ?", (order_id,)
).fetchone()
if order is None:
return {"created": False, "reason": "order_not_found"}
existing = connection.execute(
"SELECT refund_id, state FROM refunds WHERE order_id = ? AND reason = ?",
(order_id, reason),
).fetchone()
if existing is not None:
return {"created": False, "reason": "duplicate",
"refund_id": existing["refund_id"], "state": existing["state"]}
refund_amount = float(order["amount"] if amount is None else amount)
if refund_amount > MAX_REFUND_AMOUNT:
return {"created": False, "reason": "over_limit",
"message": f"金额超过 {MAX_REFUND_AMOUNT:.0f} 元需人工审核",
"amount": refund_amount}
refund_id = f"RF{order_id[-6:]}{abs(hash(idempotency_key)) % 1000:03d}"
connection.execute(
"INSERT INTO refunds VALUES (?,?,?,?,datetime('now'))",
(refund_id, order_id, reason, "已受理"),
)
return {"created": True, "refund_id": refund_id, "amount": refund_amount,
"state": "已受理", "idempotency_key": idempotency_key}
def handoff_to_human(summary: str, urgency: str = "normal") -> dict:
ticket = f"HD{abs(hash(summary)) % 100000:05d}"
return {"ticket": ticket, "urgency": urgency, "status": "已转人工",
"summary": summary}
idempotency_key(幂等键)是写操作的关键字段:同一个请求重试多次只产生一个工单。这个字段由模型填写,规则要写进工具描述;服务端也可以用自己的会话号覆盖,避免模型随手编。
query_policy里的agent.rag(检索模块)和kb/policies/(政策文档)要到第 6 步才建立,所以这里先用函数内导入,让工具注册这一步可以先行落地,第 6 步完成后再整体联调检索。函数内导入同时避免模块级循环依赖,属于这类分层工程的常见写法。
注册工具并生成模型可读的清单
在agent/tools.py文件末尾追加,紧接该文件上一个示例。
def build_registry(allow_write: bool = False) -> ToolRegistry:
registry = ToolRegistry(allow_write=allow_write)
registry.register(ToolSpec(
name="get_order",
description="按订单号查询订单状态、金额和物流单号。用户给出订单号时优先使用。",
parameters=_params({"order_id": TEXT}, ["order_id"]),
function=get_order,
side_effect=False,
))
registry.register(ToolSpec(
name="list_orders",
description="按买家编号列出最近订单,用于用户没有提供订单号时缩小范围。",
parameters=_params({"buyer_id": TEXT, "limit": {"type": "integer"}}, ["buyer_id"]),
function=list_orders,
side_effect=False,
))
registry.register(ToolSpec(
name="track_shipping",
description="查询指定订单的承运商、运单号和当前物流状态。",
parameters=_params({"order_id": TEXT}, ["order_id"]),
function=track_shipping,
side_effect=False,
))
registry.register(ToolSpec(
name="query_policy",
description="检索售后政策知识库,回答退换货期限、运费承担等规则问题。",
parameters=_params({"question": TEXT, "top_k": {"type": "integer"}},
["question"]),
function=query_policy,
side_effect=False,
))
registry.register(ToolSpec(
name="create_refund",
description="为用户创建退款工单。属于写操作,必须先向用户复述订单号、原因和金额并取得同意。",
parameters=_params(
{"order_id": TEXT, "reason": TEXT, "idempotency_key": TEXT,
"amount": {"type": "number"}},
["order_id", "reason", "idempotency_key"],
),
function=create_refund,
side_effect=True,
))
registry.register(ToolSpec(
name="handoff_to_human",
description="当用户明确要求人工、情绪激烈或涉及超权限诉求时转人工。",
parameters=_params({"summary": TEXT, "urgency": TEXT}, ["summary"]),
function=handoff_to_human,
side_effect=True,
))
return registry
if __name__ == "__main__":
from agent.store import init_db
init_db()
tools = build_registry(allow_write=True)
print(json.dumps(tools.as_openai_tools(), ensure_ascii=False, indent=2)[:800])
print("工具清单:", tools.names())
python3 -m agent.tools
description(工具描述)不是文档,是提示词的一部分:模型判断"什么时候用它"完全依赖这句话。所以描述里必须写清适用条件、不适用条件和前置约束,例如create_refund里"必须先向用户复述并取得同意"。
用python3 -m agent.tools而不是python3 agent/tools.py运行:后者会把脚本所在目录当成包搜索路径,和标准库同名模块冲突。这个现象和排查方法见工具和参数出问题。
完成检查:六个工具全部出现在工具清单中;每个参数都有类型和required;写操作工具显式标了side_effect=True;描述里能看出它和相邻工具的区别。
3. 在模型之前做参数校验,并给写操作加闸门
目标:让模型的错误参数变成结构化反馈,而不是异常或误操作。
操作位置:项目根目录,新增agent/registry.py。
先修改:MAX_REFUND_AMOUNT(金额阈值)和只读默认值allow_write=False按业务风险调整。
参数与原理见对应参考。下面按顺序操作。
模型给出的参数永远不可信:可能是字符串形式的数字、缺少必填字段、多出一个没声明的字段,甚至不是合法 JSON。这些情况都必须由我们判定,不能指望模型每次都守规矩。校验放在调用工具之前,失败结果再喂回模型让它修正,这正是智能体自我纠错的入口。
from __future__ import annotations
import inspect
import json
import time
from dataclasses import dataclass, field
from typing import Any, Callable
from agent.validation import ValidationError, coerce_arguments
@dataclass
class ToolResult:
ok: bool
data: Any = None
error_code: str = ""
error_message: str = ""
retryable: bool = False
elapsed_ms: float = 0.0
def to_payload(self) -> dict:
if self.ok:
return {"ok": True, "data": self.data}
return {
"ok": False,
"error_code": self.error_code,
"error_message": self.error_message,
"retryable": self.retryable,
}
@dataclass
class ToolSpec:
name: str
description: str
parameters: dict
function: Callable
side_effect: bool = False
idempotent: bool = True
timeout_seconds: float = 5.0
tags: list = field(default_factory=list)
def to_openai_schema(self) -> dict:
return {
"type": "function",
"function": {
"name": self.name,
"description": self.description,
"parameters": self.parameters,
},
}
class ToolRegistry:
def __init__(self, allow_write: bool = False):
self._tools: dict[str, ToolSpec] = {}
self.allow_write = allow_write
def register(self, spec: ToolSpec) -> None:
if spec.name in self._tools:
raise ValueError(f"工具名重复: {spec.name}")
if spec.parameters.get("type") != "object":
raise ValueError(f"{spec.name} 的参数根节点必须是 object")
self._tools[spec.name] = spec
def get(self, name: str) -> ToolSpec:
if name not in self._tools:
raise KeyError(name)
return self._tools[name]
def names(self) -> list[str]:
return sorted(self._tools)
def as_openai_tools(self) -> list[dict]:
return [spec.to_openai_schema() for spec in self._tools.values()]
def call(self, name: str, raw_arguments: Any) -> ToolResult:
started = time.perf_counter()
try:
spec = self.get(name)
except KeyError:
return ToolResult(False, error_code="unknown_tool",
error_message=f"没有名为 {name} 的工具", retryable=False,
elapsed_ms=(time.perf_counter() - started) * 1000)
try:
arguments = coerce_arguments(spec.parameters, raw_arguments)
except ValidationError as error:
return ToolResult(False, error_code=error.code, error_message=error.message,
retryable=False,
elapsed_ms=(time.perf_counter() - started) * 1000)
if spec.side_effect and not self.allow_write:
return ToolResult(False, error_code="write_not_allowed",
error_message="当前为只读模式,写操作未执行", retryable=False,
elapsed_ms=(time.perf_counter() - started) * 1000)
try:
signature = inspect.signature(spec.function)
data = spec.function(**arguments)
except TypeError as error:
return ToolResult(False, error_code="bad_arguments",
error_message=f"参数与实现不匹配: {error}", retryable=False,
elapsed_ms=(time.perf_counter() - started) * 1000)
except Exception as error: # 工具内部异常必须转成结构化结果,不能让主循环崩溃
return ToolResult(False, error_code="tool_error",
error_message=f"{type(error).__name__}: {error}", retryable=True,
elapsed_ms=(time.perf_counter() - started) * 1000)
return ToolResult(True, data=data, elapsed_ms=(time.perf_counter() - started) * 1000)
def dump_payload(payload: dict, limit: int = 4000) -> str:
text = json.dumps(payload, ensure_ascii=False, default=str)
if len(text) <= limit:
return text
return text[:limit] + f"...[已截断,原长度 {len(text)}]"
三个设计点要记住。第一,工具内部的任何异常都被转成ToolResult,因为主循环一旦抛错,整轮对话就结束了,用户只看到报错。第二,只读模式在注册表这一层拦截,而不是靠提示词请求模型"不要退款"——提示词是软约束,代码是硬约束。第三,dump_payload限制回填长度,工具返回几千行数据时不会把上下文撑爆。
校验函数与"模型参数不可信"清单
新建agent/validation.py。校验覆盖模型最常犯的四类错误:类型不对、缺必填、多参数、不是 JSON。
from __future__ import annotations
import json
from typing import Any
class ValidationError(Exception):
def __init__(self, code: str, message: str):
super().__init__(message)
self.code = code
self.message = message
TYPES = {
"string": str,
"integer": int,
"number": (int, float),
"boolean": bool,
"object": dict,
"array": list,
}
def _coerce(value: Any, kind: str) -> Any:
if isinstance(value, bool) and kind != "boolean":
raise ValidationError("bad_type", f"布尔值不能用作 {kind}")
if kind == "integer":
if isinstance(value, int):
return value
if isinstance(value, str) and value.strip().lstrip("-").isdigit():
return int(value.strip())
raise ValidationError("bad_type", f"需要整数,收到 {type(value).__name__}")
if kind == "number":
if isinstance(value, (int, float)):
return float(value)
raise ValidationError("bad_type", f"需要数字,收到 {type(value).__name__}")
expected = TYPES[kind]
if isinstance(expected, tuple):
ok = isinstance(value, expected)
else:
ok = isinstance(value, expected)
if not ok:
raise ValidationError("bad_type", f"需要 {kind},收到 {type(value).__name__}")
return value
def coerce_arguments(schema: dict, raw_arguments: Any) -> dict:
if raw_arguments is None or raw_arguments == "":
raw_arguments = {}
if isinstance(raw_arguments, str):
try:
raw_arguments = json.loads(raw_arguments)
except json.JSONDecodeError as error:
raise ValidationError("bad_json", f"参数不是合法 JSON: {error}") from error
if not isinstance(raw_arguments, dict):
raise ValidationError("bad_json", "参数必须是 JSON 对象")
if schema.get("type") != "object":
raise ValidationError("bad_schema", "工具参数根节点必须是 object")
properties = schema.get("properties", {})
if schema.get("additionalProperties") is False:
unknown = sorted(set(raw_arguments) - set(properties))
if unknown:
raise ValidationError("unknown_argument", f"出现未声明参数: {', '.join(unknown)}")
cleaned: dict = {}
for name, rule in properties.items():
if name in raw_arguments:
value = raw_arguments[name]
if value is None:
if name in schema.get("required", []):
raise ValidationError("missing_argument",
f"必填参数 {name} 不能为空")
continue
cleaned[name] = _coerce(value, rule.get("type", "string"))
elif "default" in rule:
cleaned[name] = rule["default"]
elif name in schema.get("required", []):
raise ValidationError("missing_argument", f"缺少必填参数 {name}")
if "enum" in schema:
if cleaned not in schema["enum"]:
raise ValidationError("bad_enum", "取值不在允许列表中")
for name, rule in properties.items():
if name in cleaned and "enum" in rule and cleaned[name] not in rule["enum"]:
raise ValidationError("bad_enum", f"参数 {name} 取值 {cleaned[name]} 不在允许列表中")
return cleaned
可选参数传null时直接跳过、不写入cleaned,让工具函数的默认值生效;必填参数传null才算缺参。这个约定要和工具签名保持一致,否则会出现"校验通过但调用报 TypeError"。
先手工验证闸门,再交给模型
python3 - <<'PY'
from agent.store import init_db
from agent.tools import build_registry
init_db()
registry = build_registry(allow_write=False)
print(registry.call("create_refund",
'{"order_id":"SO20241105001","reason":"七天无理由","idempotency_key":"k1"}').to_payload())
print(registry.call("get_order", {"order_id": "SO20241105001"}).to_payload())
print(registry.call("get_order", '{"limit":3}').to_payload())
PY
预期观察:第一条返回write_not_allowed;第二条返回订单事实;第三条返回unknown_argument而不是静默忽略limit。如果第三条变成了成功,说明additionalProperties没有生效,先去查工具和参数出问题。
完成检查:只读模式下写操作被拦截并可解释;未知工具返回结构化错误;缺参、错类型、坏 JSON 都有独立错误码;工具内部异常可被标记为retryable。
4. 写出受约束的对话循环
目标:让模型能连续多轮调用工具,同时在步数、错误和空回复上都有明确出口。
操作位置:项目根目录,新增agent/agent.py和agent/prompt.py。
先修改:max_steps(最大轮数)默认 6;模型名、温度、最大输出长度按成本和稳定性调整。
参数与原理见对应参考。下面按顺序操作。
循环是整个智能体的心脏,它的逻辑可以压成一句话:把历史和工具清单发给模型;如果它给出工具调用就执行、把结果追加回消息列表、继续下一轮;如果它给出最终文字就结束。容易出错的地方不在主干,而在出口:模型反复调用同一个工具怎么办、工具报错怎么办、模型既没有工具调用也没有文字怎么办。
固定系统提示词
新建agent/prompt.py。提示词只写模型无法从工具定义推断出来的东西:事实来源、缺信息时的行为、写操作的前置条件、失败时的说法、引用的格式。
SYSTEM_PROMPT = """你是电商平台的售后助理。你可以调用工具查询订单、物流和政策。
工作规则:
1. 只根据工具返回的事实回答,不要编造订单状态、金额、时效或政策条款。
2. 缺少订单号等关键信息时,先向用户提出一个明确的问题,不要猜测参数。
3. 创建退款、转人工属于写操作。执行前必须复述订单号、原因和金额,并等待用户确认。
4. 工具返回 ok 为 false 时,如实说明原因;可重试的错误最多重试一次。
5. 政策回答必须引用工具片段中的来源编号,例如 refund.md#2。
6. 用户明确要求人工或涉及超权限诉求时,调用 handoff_to_human。
输出要求:先给结论,再给依据,最后给下一步动作。不要展示工具调用细节。"""
def build_messages(system_prompt: str = SYSTEM_PROMPT, history: list | None = None,
question: str = "") -> list:
messages = [{"role": "system", "content": system_prompt}]
messages.extend(history or [])
messages.append({"role": "user", "content": question})
return messages
"缺少订单号时先追问"这条规则不能省。默认情况下模型倾向于猜一个看起来合理的订单号去完成任务,而追问一次的成本远低于执行一次错误操作。
写入对话循环
新建agent/agent.py。_complete负责带重试的模型调用,run负责工具循环和停止判定。
from __future__ import annotations
import os
import time
from dataclasses import dataclass, field
from agent.prompt import SYSTEM_PROMPT, build_messages
from agent.registry import ToolRegistry, dump_payload
from agent.tracing import TraceRecorder
MAX_STEPS_DEFAULT = 6
MODEL_DEFAULT = os.environ.get("AGENT_MODEL", "deepseek-chat")
@dataclass
class StepRecord:
index: int
tool: str
arguments: dict
ok: bool
error_code: str = ""
elapsed_ms: float = 0.0
@dataclass
class AgentResult:
answer: str
finished: bool
stop_reason: str
steps: list = field(default_factory=list)
tool_calls: list = field(default_factory=list)
usage: dict = field(default_factory=dict)
trace_id: str = ""
messages: list = field(default_factory=list)
class Agent:
def __init__(self, registry: ToolRegistry, client=None, model: str = MODEL_DEFAULT,
max_steps: int = MAX_STEPS_DEFAULT, temperature: float = 0.2,
max_tokens: int = 800, system_prompt: str = SYSTEM_PROMPT,
echo_trace: bool = True):
if client is None:
from openai import OpenAI
client = OpenAI(
api_key=os.environ["AGENT_API_KEY"],
base_url=os.environ.get("AGENT_BASE_URL", "https://api.deepseek.com/v1"),
timeout=30.0,
max_retries=2,
)
self.client = client
self.registry = registry
self.model = model
self.max_steps = max_steps
self.temperature = temperature
self.max_tokens = max_tokens
self.system_prompt = system_prompt
self.echo_trace = echo_trace
def _complete(self, messages: list, recorder: TraceRecorder):
for attempt in range(2):
try:
started = time.perf_counter()
response = self.client.chat.completions.create(
model=self.model,
messages=messages,
tools=self.registry.as_openai_tools(),
tool_choice="auto",
temperature=self.temperature,
max_tokens=self.max_tokens,
)
elapsed = (time.perf_counter() - started) * 1000
recorder.add_usage(getattr(response, "usage", None))
recorder.span("llm_call", attempt=attempt, elapsed_call_ms=round(elapsed, 2),
model=self.model, message_count=len(messages))
return response.choices[0].message
except Exception as error: # 网络与限流错误:最多重试一次
recorder.span("llm_error", attempt=attempt, error=type(error).__name__,
message=str(error)[:200])
if attempt == 1:
raise
time.sleep(0.5)
raise RuntimeError("unreachable")
def run(self, question: str, history: list | None = None, on_event=None) -> AgentResult:
recorder = TraceRecorder(echo=self.echo_trace)
messages = build_messages(self.system_prompt, history, question)
steps: list[StepRecord] = []
called: list[str] = []
stop_reason = "max_steps"
answer = ""
finished = False
emit = on_event or (lambda name, payload: None)
recorder.span("user_message", question=question)
for index in range(self.max_steps):
message = self._complete(messages, recorder)
tool_calls = getattr(message, "tool_calls", None) or []
if not tool_calls:
answer = (message.content or "").strip()
finished = bool(answer)
stop_reason = "final_answer" if finished else "empty_answer"
break
messages.append({
"role": "assistant",
"content": message.content or "",
"tool_calls": [
{
"id": call.id,
"type": "function",
"function": {"name": call.function.name,
"arguments": call.function.arguments},
}
for call in tool_calls
],
})
for call in tool_calls:
name = call.function.name
emit("tool_start", {"tool": name})
result = self.registry.call(name, call.function.arguments)
called.append(name)
recorder.span("tool_call", step=index, tool=name,
ok=result.ok, error_code=result.error_code,
elapsed_ms=round(result.elapsed_ms, 2))
steps.append(StepRecord(index=index, tool=name,
arguments=_safe_arguments(call.function.arguments),
ok=result.ok, error_code=result.error_code,
elapsed_ms=round(result.elapsed_ms, 2)))
emit("tool_end", {"tool": name, "ok": result.ok,
"error_code": result.error_code})
messages.append({
"role": "tool",
"tool_call_id": call.id,
"content": dump_payload(result.to_payload()),
})
summary = recorder.summary(stop_reason=stop_reason, finished=finished,
tool_calls=called)
recorder.span("run_end", **summary)
return AgentResult(answer=answer, finished=finished, stop_reason=stop_reason,
steps=steps, tool_calls=called, usage=recorder.usage,
trace_id=recorder.trace_id, messages=messages)
def _safe_arguments(arguments) -> dict:
import json
if isinstance(arguments, dict):
return arguments
try:
return json.loads(arguments or "{}")
except json.JSONDecodeError:
return {"_raw": str(arguments)[:120]}
四个出口都要能解释:final_answer表示正常结束;max_steps表示模型在打转;empty_answer表示模型既没给工具也没给文字;模型调用连续失败时_complete抛出异常,由服务层转成可读回复,不要让异常直接冒到用户面前。
助手消息必须完整回填tool_calls,工具结果必须带tool_call_id。少任何一个,下一轮请求都会因为消息序列不合法被拒绝,现象和排查见循环和话术出问题。
用命令行入口跑通一轮
新建run_agent.py。
from __future__ import annotations
import argparse
import json
from agent.agent import Agent
from agent.tools import build_registry
def main() -> None:
parser = argparse.ArgumentParser(description="售后智能体命令行入口")
parser.add_argument("--question", required=True, help="用户问题")
parser.add_argument("--session", default="", help="会话编号,便于串联多轮")
parser.add_argument("--allow-write", action="store_true", help="允许执行写操作")
parser.add_argument("--max-steps", type=int, default=6, help="工具调用最大轮数")
args = parser.parse_args()
registry = build_registry(allow_write=args.allow_write)
agent = Agent(registry=registry, max_steps=args.max_steps)
result = agent.run(args.question)
print(json.dumps({"answer": result.answer, "finished": result.finished,
"stop_reason": result.stop_reason,
"tool_calls": result.tool_calls,
"usage": result.usage, "trace_id": result.trace_id},
ensure_ascii=False, indent=2))
if __name__ == "__main__":
from agent.store import init_db
init_db()
main()
python3 run_agent.py --question "SO20241106002 的包裹到哪了?"
python3 run_agent.py --question "帮我把 SO20241105001 退款" --allow-write
第一次运行先用只读模式,确认查询类任务正常;确认提示词和工具描述都稳定后,再用--allow-write试写操作。
完成检查:查询类问题能在一到两轮内给出带事实的回答;缺订单号时会先追问;工具报错时回答里说明了原因;只读模式下不会真的产生退款记录。
5. 管好记忆:滑动窗口、成对保留与摘要压缩
目标:让多轮对话有上下文,同时不把上下文无限撑大。
操作位置:项目根目录,新增agent/memory.py。
先修改:max_rounds(保留轮数)默认 6、max_chars(字符预算)默认 6000,按模型上下文长度和成本调整。
参数与原理见对应参考。下面按顺序操作。
智能体的历史消息里混着三种内容:用户问题、助手回复,以及工具调用和工具结果。裁剪时最危险的动作是从中间随便切一刀,因为tool消息必须紧跟在带tool_calls的助手消息之后。下面是能保证成对关系的裁剪方式。
from __future__ import annotations
import json
from collections import deque
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
SESSION_DIR = ROOT / "reports" / "sessions"
def trim_history(history: list, max_rounds: int = 6, max_chars: int = 6000) -> list:
"""按最近轮次和总长度裁剪,保证 tool_calls 与 tool 结果成对保留。"""
kept = list(history[-(max_rounds * 2):])
while kept and sum(len(str(item.get("content") or "")) for item in kept) > max_chars:
kept.pop(0)
return kept
class SessionStore:
def __init__(self, directory: Path = SESSION_DIR):
self.directory = directory
self.directory.mkdir(parents=True, exist_ok=True)
def path(self, session_id: str) -> Path:
safe = "".join(ch for ch in session_id if ch.isalnum() or ch in "-_") or "default"
return self.directory / f"{safe}.json"
def load(self, session_id: str) -> list:
path = self.path(session_id)
if not path.exists():
return []
return json.loads(path.read_text(encoding="utf-8"))
def save(self, session_id: str, history: list) -> None:
self.path(session_id).write_text(
json.dumps(history, ensure_ascii=False, indent=2), encoding="utf-8"
)
def append_turn(self, session_id: str, question: str, answer: str,
max_rounds: int = 6) -> list:
history = self.load(session_id)
history.append({"role": "user", "content": question})
history.append({"role": "assistant", "content": answer})
history = trim_history(history, max_rounds=max_rounds)
self.save(session_id, history)
return history
append_turn只存用户问题和最终回答,不存中间的tool_calls。这是一个明确的取舍:中间过程的主要用途是当前这轮的推理,事后存档只保留结论可以省下大量上下文;如果业务需要审计完整过程,应该写进轨迹日志,而不是塞回对话历史。
会话编号会直接进文件名,所以必须过滤字符。示例里用白名单保留字母、数字、连字符和下划线,把其他字符全部丢掉;不要直接把用户输入当路径拼进去。
完成检查:同一会话连续两次提问,第二次能看到第一次的结论;超长历史被裁掉后,模型调用不再报消息序列错误;reports/sessions/下每个会话一个文件。
6. 给政策类问题加检索,并要求带来源回答
目标:让"能不能退""多久到账"这类问题有可追溯依据,而不是靠模型记忆。
操作位置:项目根目录,新增kb/policies/文档和agent/rag.py。
先修改:MAX_CHARS(片段长度)默认 300、top_k默认 3、min_score(最低分)默认 3.0。
参数与原理见对应参考。下面按顺序操作。
工具调用解决"取数",检索解决"取规则"。两者都要给模型事实,但检索多一层要求:回答必须指出条款来自哪一段,否则用户无法核对,评测也无法定位错误来自检索还是话术。
准备政策文档
新建kb/policies/refund.md。
# 退换货政策
## 七天无理由退货
自签收之日起七天内,商品不影响二次销售可以申请无理由退货。
超过七天但仍在十五天内的,只接受质量问题退货,需要先提交照片凭证。
## 运费承担
无理由退货的往返运费由买家承担;质量问题退货的运费由平台承担。
使用平台上门取件时,运费从退款金额中直接扣除。
## 退款到账时间
审核通过后原路退回,银行卡通常一至三个工作日到账,余额当天到账。
退款进度可以在订单详情中查询,客服不能人工加速银行到账。
新建kb/policies/shipping.md。
# 物流与发票政策
## 物流时效
现货商品付款后四十八小时内发出,预售商品以商品页标注的发货时间为准。
运输途中超过七十二小时没有新轨迹时,客服可以代用户发起物流查询。
## 签收异常
显示已签收但用户没有收到时,先核对签收人和代收点,再提交包裹查询工单。
## 发票
电子发票在订单完成后三个工作日内发送到下单邮箱。
需要改开公司抬头时,请在开票前联系客服;已开出的发票需要先冲红再重开。
切分文档并检索
新建agent/rag.py。切分按标题分块,块内再按句子切到长度上限;检索先用字符重合打分,保证不联网、可复现,等业务文档变大再换向量方案。
from __future__ import annotations
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
POLICY_DIR = ROOT / "kb" / "policies"
HEADING = re.compile(r"^#{1,3}\s+")
MAX_CHARS = 300
def split_document(text: str, max_chars: int = MAX_CHARS) -> list[str]:
blocks = []
buffer: list[str] = []
for line in text.splitlines():
if HEADING.match(line) and buffer:
blocks.append("\n".join(buffer).strip())
buffer = [line]
else:
buffer.append(line)
if buffer:
blocks.append("\n".join(buffer).strip())
chunks: list[str] = []
for block in blocks:
if len(block) <= max_chars:
chunks.append(block)
continue
sentences = re.split(r"(?<=[。;!?])", block)
current = ""
for sentence in sentences:
if len(current) + len(sentence) > max_chars and current:
chunks.append(current.strip())
current = sentence
else:
current += sentence
if current.strip():
chunks.append(current.strip())
return [chunk for chunk in chunks if chunk]
def load_chunks() -> list[dict]:
chunks = []
for path in sorted(POLICY_DIR.glob("*.md")):
for index, text in enumerate(split_document(path.read_text(encoding="utf-8"))):
chunks.append({"source": f"{path.name}#{index + 1}", "text": text})
return chunks
def score(query: str, chunk: dict) -> float:
text = chunk["text"]
overlap = sum(1 for token in set(query) if token in text)
head = sum(3 for token in set(query) if token in text[:40])
return float(overlap + head)
def retrieve(query: str, top_k: int = 3, min_score: float = 3.0) -> list[dict]:
ranked = [dict(chunk, score=score(query, chunk)) for chunk in load_chunks()]
ranked = [item for item in ranked if item["score"] >= min_score]
ranked.sort(key=lambda item: (-item["score"], item["source"]))
return ranked[:top_k]
def format_evidence(hits: list[dict]) -> str:
if not hits:
return "知识库没有命中相关条款,请如实告知用户并转人工。"
lines = [f"[{item['source']}] {item['text']}" for item in hits]
return "\n".join(lines)
检索命中数和最低分是两个容易混淆的参数:top_k限制给模型几段依据,min_score决定"没有依据"时是否返回空。空结果是一种合法返回,模型应该据此说不知道,而不是硬凑一个答案。
验证检索能带出正确来源
python3 - <<'PY'
from agent.tools import build_registry
registry = build_registry()
payload = registry.call("query_policy", {"question": "七天无理由退货的运费谁承担"}).to_payload()
for hit in payload["data"]["hits"]:
print(hit["source"], hit["score"], hit["text"][:40])
PY
预期看到refund.md#5这类来源编号,且片段内容确实在回答运费由谁承担。如果来源编号和内容对不上,说明切分或来源标注有错,先查检索和引用出问题。
完成检查:政策问题能命中正确文件;回答里出现来源编号;问一个文档里没有的问题时返回空结果并转人工。
7. 用替身模型做离线测试,不花一次调用费
目标:在不联网、不产生费用的前提下验证循环、校验、闸门和事件。
操作位置:项目根目录,新增tests/test_agent.py。
先修改:替身模型的预设脚本要覆盖真实场景,不能只测顺利路径。
参数与原理见对应参考。下面按顺序操作。
评测之前必须先能测。智能体最难测的地方是它依赖模型输出,而模型输出不可控;替身模型(fake client)把"模型说了什么"变成输入数据,于是循环逻辑就成了可断言的普通代码。这样做还有第二个好处:真实模型换了版本、换了温度,回归结果仍然可比。
新建tests/test_agent.py。
"""离线可运行的智能体测试:用替身模型驱动完整工具循环。"""
from __future__ import annotations
import json
import unittest
from pathlib import Path
from types import SimpleNamespace
from agent.agent import Agent
from agent.registry import ToolRegistry, ToolSpec
from agent.store import init_db
from agent.tools import build_registry
from agent.validation import ValidationError, coerce_arguments
ORDER_SCHEMA = {
"type": "object",
"properties": {"order_id": {"type": "string"}, "limit": {"type": "integer"}},
"required": ["order_id"],
"additionalProperties": False,
}
def make_tool_call(name: str, arguments: dict, call_id: str = "call_1"):
return SimpleNamespace(id=call_id, type="function",
function=SimpleNamespace(name=name,
arguments=json.dumps(arguments)))
class FakeCompletions:
def __init__(self, script):
self.script = list(script)
self.requests = []
def create(self, **kwargs):
self.requests.append(kwargs)
message = self.script.pop(0) if self.script else SimpleNamespace(
content="没有更多预设回复", tool_calls=None
)
return SimpleNamespace(
choices=[SimpleNamespace(message=message)],
usage=SimpleNamespace(prompt_tokens=100, completion_tokens=20,
total_tokens=120),
)
class FakeClient:
def __init__(self, script):
self.chat = SimpleNamespace(completions=FakeCompletions(script))
def assistant(content="", tool_calls=None):
return SimpleNamespace(content=content, tool_calls=tool_calls)
class ValidationTest(unittest.TestCase):
def test_missing_required_field(self):
with self.assertRaises(ValidationError) as ctx:
coerce_arguments(ORDER_SCHEMA, {"limit": 3})
self.assertEqual(ctx.exception.code, "missing_argument")
def test_rejects_unknown_field(self):
with self.assertRaises(ValidationError) as ctx:
coerce_arguments(ORDER_SCHEMA, {"order_id": "SO1", "debug": True})
self.assertEqual(ctx.exception.code, "unknown_argument")
def test_coerces_string_number(self):
cleaned = coerce_arguments(ORDER_SCHEMA, '{"order_id":"SO1","limit":"3"}')
self.assertEqual(cleaned["limit"], 3)
def test_rejects_bad_json(self):
with self.assertRaises(ValidationError) as ctx:
coerce_arguments(ORDER_SCHEMA, "{order_id: SO1}")
self.assertEqual(ctx.exception.code, "bad_json")
def test_ignores_explicit_null_optional(self):
cleaned = coerce_arguments(ORDER_SCHEMA, {"order_id": "SO1", "limit": None})
self.assertNotIn("limit", cleaned)
class RegistryTest(unittest.TestCase):
def setUp(self):
self.registry = ToolRegistry(allow_write=False)
self.registry.register(ToolSpec(
name="echo", description="回显", function=lambda order_id: {"got": order_id},
parameters=ORDER_SCHEMA, side_effect=False,
))
def test_unknown_tool_returns_structured_error(self):
result = self.registry.call("not_exist", {})
self.assertFalse(result.ok)
self.assertEqual(result.error_code, "unknown_tool")
def test_tool_exception_is_captured(self):
registry = ToolRegistry()
registry.register(ToolSpec(
name="boom", description="抛错", parameters=ORDER_SCHEMA,
function=lambda order_id: 1 / 0, side_effect=False,
))
result = registry.call("boom", {"order_id": "SO1"})
self.assertTrue(result.retryable)
self.assertEqual(result.error_code, "tool_error")
def test_write_blocked_in_readonly_mode(self):
registry = ToolRegistry(allow_write=False)
registry.register(ToolSpec(
name="write_it", description="写", function=lambda order_id: {"ok": True},
parameters=ORDER_SCHEMA, side_effect=True,
))
result = registry.call("write_it", {"order_id": "SO1"})
self.assertEqual(result.error_code, "write_not_allowed")
class AgentLoopTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
init_db()
def test_two_step_loop_reaches_final_answer(self):
client = FakeClient([
assistant(tool_calls=[make_tool_call("get_order", {"order_id": "SO20241105001"})]),
assistant(content="结论:该订单已签收。依据:订单状态为已签收。"),
])
agent = Agent(registry=build_registry(), client=client, echo_trace=False)
result = agent.run("SO20241105001 到哪了?")
self.assertTrue(result.finished)
self.assertEqual(result.stop_reason, "final_answer")
self.assertEqual(result.tool_calls, ["get_order"])
self.assertIn("已签收", result.answer)
self.assertEqual(result.usage["total_tokens"], 240)
self.assertEqual(client.chat.completions.requests[0]["tool_choice"], "auto")
def test_loop_stops_at_max_steps(self):
script = [assistant(tool_calls=[make_tool_call("get_order",
{"order_id": "SO1"},
call_id=f"c{i}")])
for i in range(3)]
agent = Agent(registry=build_registry(), client=FakeClient(script),
max_steps=3, echo_trace=False)
result = agent.run("查订单")
self.assertFalse(result.finished)
self.assertEqual(result.stop_reason, "max_steps")
self.assertEqual(len(result.steps), 3)
def test_tool_error_is_fed_back_not_raised(self):
client = FakeClient([
assistant(tool_calls=[make_tool_call("track_shipping", {"order_id": "SO_NOT_EXIST"})]),
assistant(content="这单查不到物流,请核对订单号。"),
])
agent = Agent(registry=build_registry(), client=client, echo_trace=False)
result = agent.run("帮我查物流")
self.assertTrue(result.finished)
tool_message = [m for m in result.messages if m["role"] == "tool"][0]
self.assertIn('"ok": true', tool_message["content"])
def test_write_blocked_keeps_agent_honest(self):
client = FakeClient([
assistant(tool_calls=[make_tool_call("create_refund",
{"order_id": "SO20241105001",
"reason": "七天无理由",
"idempotency_key": "k1"})]),
assistant(content="当前为只读模式,我没有创建退款。"),
])
agent = Agent(registry=build_registry(allow_write=False), client=client,
echo_trace=False)
result = agent.run("帮我退款")
self.assertFalse(result.steps[0].ok)
self.assertEqual(result.steps[0].error_code, "write_not_allowed")
self.assertIn("只读", result.answer)
def test_trace_file_written(self):
client = FakeClient([assistant(content="直接回答")])
agent = Agent(registry=build_registry(), client=client, echo_trace=False)
result = agent.run("你好")
path = Path("reports") / "traces" / f"{result.trace_id}.jsonl"
lines = path.read_text(encoding="utf-8").strip().splitlines()
self.assertGreaterEqual(len(lines), 3)
first = json.loads(lines[0])
self.assertEqual(first["span"], "user_message")
class RagTest(unittest.TestCase):
def test_retrieve_returns_source_tag(self):
from agent.rag import retrieve
hits = retrieve("七天无理由退货的运费谁承担")
self.assertTrue(hits)
self.assertTrue(all("source" in hit for hit in hits))
self.assertTrue(any("refund.md" in hit["source"] for hit in hits))
def test_chunk_length_bounded(self):
from agent.rag import load_chunks
chunks = load_chunks()
self.assertTrue(chunks)
self.assertTrue(all(len(chunk["text"]) <= 320 for chunk in chunks))
class EventCallbackTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
init_db()
def test_tool_events_are_emitted_in_order(self):
client = FakeClient([
assistant(tool_calls=[make_tool_call("get_order", {"order_id": "SO20241105001"})]),
assistant(content="已查到。"),
])
events = []
agent = Agent(registry=build_registry(), client=client, echo_trace=False)
agent.run("查订单", on_event=lambda name, payload: events.append(name))
self.assertEqual(events, ["tool_start", "tool_end"])
class MemoryTest(unittest.TestCase):
def test_trim_history_keeps_recent_rounds(self):
from agent.memory import trim_history
history = [{"role": "user", "content": f"q{index}"} for index in range(20)]
self.assertEqual(len(trim_history(history, max_rounds=3)), 6)
def test_trim_history_respects_char_budget(self):
from agent.memory import trim_history
history = [{"role": "user", "content": "x" * 100} for _ in range(10)]
trimmed = trim_history(history, max_rounds=10, max_chars=350)
self.assertLessEqual(sum(len(item["content"]) for item in trimmed), 350)
if __name__ == "__main__":
unittest.main(verbosity=2)
python3 -m unittest discover -s tests -v
本文这一套测试共 18 项,覆盖参数校验五类错误、注册表三类失败、循环四个出口、事件顺序、记忆裁剪和检索。它们全部离线运行,单次耗时不到 0.1 秒,可以放进每次提交的检查里。
完成检查:测试全绿;把max_steps临时改成 1 后能看到对应测试失败,说明断言真的在起作用;测试不访问网络。
8. 用带标注的任务清单评测效果
目标:把"感觉还行"换成可比较的数字。
操作位置:项目根目录,新增data/golden_tasks.jsonl和eval.py。
先修改:清单里的问题、期望工具和是否应结束都要人工标注;不要用模型自动生成答案当标准答案。
参数与原理见对应参考。下面按顺序操作。
评测的第一步是定口径。同一句"任务成功率"在不同团队里可以指完全不同的东西,所以本文把它拆成四个可分别计算的指标:工具选择是否精确一致、工具召回是否漏掉必要步骤、参数是否正确、需要追问时是否真的问了。这些指标都能从运行轨迹里算出来,不依赖主观打分。
建立标注清单
新建data/golden_tasks.jsonl,一行一个任务。should_finish为false表示这题的正确行为是追问或等待确认,不是直接给结论。
{"task_id": "T01", "question": "帮我查一下订单 SO20241105001。", "expect_tools": ["get_order"], "expect_arguments": {"get_order": {"order_id": "SO20241105001"}}, "should_finish": true, "note": "直接给出订单号"}
{"task_id": "T02", "question": "SO20241106002 的包裹到哪了?", "expect_tools": ["get_order", "track_shipping"], "expect_arguments": {"get_order": {"order_id": "SO20241106002"}}, "should_finish": true, "note": "先查订单再查物流"}
{"task_id": "T03", "question": "用户 U1002 最近有哪些订单?", "expect_tools": ["list_orders"], "expect_arguments": {"list_orders": {"buyer_id": "U1002"}}, "should_finish": true, "note": "只有买家编号"}
{"task_id": "T04", "question": "七天无理由退货的运费由谁承担?", "expect_tools": ["query_policy"], "expect_arguments": {"query_policy": {}}, "should_finish": true, "note": "纯政策问题,必须引用来源"}
{"task_id": "T05", "question": "退款一般多久到账?", "expect_tools": ["query_policy"], "expect_arguments": {"query_policy": {}}, "should_finish": true, "note": "政策问题,不应编造时效"}
{"task_id": "T06", "question": "显示已签收但是我没收到货,怎么办?", "expect_tools": ["query_policy"], "expect_arguments": {"query_policy": {}}, "should_finish": true, "note": "签收异常的规则检索"}
{"task_id": "T07", "question": "我的订单有点问题。", "expect_tools": [], "expect_arguments": {}, "should_finish": false, "note": "缺关键信息,应先追问而不是猜参数"}
{"task_id": "T08", "question": "帮我把 SO20241105001 退款,不要了。", "expect_tools": ["get_order"], "expect_arguments": {"get_order": {"order_id": "SO20241105001"}}, "should_finish": false, "note": "写操作,必须先复述并等确认"}
{"task_id": "T09", "question": "我要投诉,马上给我转人工!", "expect_tools": ["handoff_to_human"], "expect_arguments": {}, "should_finish": true, "note": "情绪激烈,直接转人工"}
{"task_id": "T10", "question": "SO20241107003 这单能退多少钱?", "expect_tools": ["get_order"], "expect_arguments": {"get_order": {"order_id": "SO20241107003"}}, "should_finish": true, "note": "需要订单金额才能回答"}
{"task_id": "T11", "question": "预售商品多久发货?", "expect_tools": ["query_policy"], "expect_arguments": {"query_policy": {}}, "should_finish": true, "note": "物流政策检索"}
{"task_id": "T12", "question": "把订单 SO20241105001 全额退款,我确认。", "expect_tools": ["create_refund"], "expect_arguments": {"create_refund": {"order_id": "SO20241105001"}}, "should_finish": true, "note": "已明确确认,写操作需 idempotency_key"}
清单要包含三类任务:正常可完成、缺少信息应追问、越权或需要人工的。只标正常路径的清单会高估效果,因为追问和拒绝恰恰是最容易做错的部分。
计算指标并输出报告
新建eval.py。为了让评测可离线运行,示例用清单驱动替身模型;换成真实模型时只替换build_client,指标计算完全不变。
from __future__ import annotations
import argparse
import json
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parent
GOLDEN = ROOT / "data" / "golden_tasks.jsonl"
REPORT_DIR = ROOT / "reports"
PROMPT_PRICE = 0.001
COMPLETION_PRICE = 0.002
def load_tasks(path: Path = GOLDEN) -> list[dict]:
tasks = []
for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
line = line.strip()
if not line:
continue
try:
tasks.append(json.loads(line))
except json.JSONDecodeError as error:
raise ValueError(f"{path.name} 第 {line_number} 行不是合法 JSON: {error}") from error
return tasks
def build_client(task: dict, final_text: str = "好的,已按规则处理。"):
from tests.test_agent import FakeClient, assistant, make_tool_call
script = []
for index, name in enumerate(task["expect_tools"]):
arguments = task["expect_arguments"].get(name, {})
if name == "create_refund" and "idempotency_key" not in arguments:
arguments = {**arguments, "idempotency_key": f"{task['task_id']}-1"}
script.append(assistant(tool_calls=[make_tool_call(name, arguments,
call_id=f"{task['task_id']}-{index}")]))
script.append(assistant(content=final_text))
return FakeClient(script)
def compare_tools(expected: list[str], called: list[str]) -> tuple[bool, float]:
exact = expected == called
matched = sum(1 for index, name in enumerate(expected) if index < len(called)
and called[index] == name)
recall = matched / len(expected) if expected else 1.0
return exact, round(recall, 3)
def compare_arguments(expected: dict, steps: list) -> float:
flat: list[tuple[str, dict]] = [(step.tool, step.arguments) for step in steps]
checks = 0
hits = 0
for tool, wanted in expected.items():
for key, value in wanted.items():
checks += 1
if any(name == tool and args.get(key) == value for name, args in flat):
hits += 1
return round(hits / checks, 3) if checks else 1.0
def percentile(values: list[float], ratio: float) -> float:
if not values:
return 0.0
ordered = sorted(values)
index = min(len(ordered) - 1, max(0, round(ratio * (len(ordered) - 1))))
return round(ordered[index], 2)
def evaluate(allow_write: bool = True, echo_trace: bool = False) -> dict:
from agent.agent import Agent
from agent.store import init_db
from agent.tools import build_registry
init_db()
tasks = load_tasks()
rows = []
for task in tasks:
registry = build_registry(allow_write=allow_write)
agent = Agent(registry=registry, client=build_client(task),
max_steps=max(4, len(task["expect_tools"]) + 2),
echo_trace=echo_trace)
started = time.perf_counter()
result = agent.run(task["question"])
latency = (time.perf_counter() - started) * 1000
exact, recall = compare_tools(task["expect_tools"], result.tool_calls)
hit_refusal = (not task["should_finish"]) or result.finished
rows.append({
"task_id": task["task_id"],
"question": task["question"],
"expect_tools": task["expect_tools"],
"called_tools": result.tool_calls,
"tool_exact": exact,
"tool_recall": recall,
"argument_accuracy": compare_arguments(task["expect_arguments"], result.steps),
"finished": result.finished,
"stop_reason": result.stop_reason,
"expect_finish": task["should_finish"],
"row_pass": bool(exact and hit_refusal),
"steps": len(result.steps),
"latency_ms": round(latency, 2),
"total_tokens": result.usage.get("total_tokens", 0),
"trace_id": result.trace_id,
})
total = len(rows)
latencies = [row["latency_ms"] for row in rows]
prompt_tokens = sum(row["total_tokens"] for row in rows)
summary = {
"tasks": total,
"task_success_rate": round(sum(row["row_pass"] for row in rows) / total, 3),
"tool_exact_rate": round(sum(row["tool_exact"] for row in rows) / total, 3),
"tool_recall": round(sum(row["tool_recall"] for row in rows) / total, 3),
"argument_accuracy": round(sum(row["argument_accuracy"] for row in rows) / total, 3),
"avg_steps": round(sum(row["steps"] for row in rows) / total, 2),
"latency_p50_ms": percentile(latencies, 0.5),
"latency_p95_ms": percentile(latencies, 0.95),
"total_tokens": prompt_tokens,
"cost_estimate_cny": round(prompt_tokens / 1000 * COMPLETION_PRICE, 4),
"allow_write": allow_write,
"note": "替身模型只验证循环与判定口径;真实模型需另跑同一清单",
}
return {"summary": summary, "rows": rows}
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="售后智能体离线回归评估")
parser.add_argument("--allow-write", action="store_true", default=True)
parser.add_argument("--report-name", default="baseline")
args = parser.parse_args()
payload = evaluate(allow_write=args.allow_write)
REPORT_DIR.mkdir(exist_ok=True)
(REPORT_DIR / f"eval_{args.report_name}.json").write_text(
json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8"
)
print(json.dumps(payload["summary"], ensure_ascii=False, indent=2))
failed = [row["task_id"] for row in payload["rows"] if not row["row_pass"]]
print("未通过任务:", failed or "无")
python3 eval.py --report-name baseline
替身模型的结果必然是满分,这不是效果证据,而是口径证据:它证明清单、判定和指标计算这条链路是通的。真实模型要跑同一份清单,用--report-name real_v1另存报告,两份报告对比才有意义。别在同一份报告名上覆盖,否则无法比较。
cost_estimate_cny只是按给定单价做的量级估算,不同服务的计价方式差别很大,不能用它做采购结论。
完成检查:报告文件包含逐任务明细和汇总;未通过任务为空;把某条清单的expect_tools改错后能立刻看到失败任务,说明判定有效。
9. 记录每次运行的轨迹,并脱敏
目标:出问题时能回答"模型当时看到了什么、调了什么、花了多久"。
操作位置:项目根目录,新增agent/tracing.py。
先修改:SENSITIVE_KEYS(敏感字段名单)按自身合规要求补充;TRACE_DIR(轨迹目录)要定期清理或归档。
参数与原理见对应参考。下面按顺序操作。
没有轨迹的智能体是不可运维的:用户说"昨天它答错了",你连当时调了哪个工具都不知道。轨迹的最低要求是三件事——每一步的名称、耗时和输入输出摘要;更高要求是能按trace_id把一次对话的模型调用和工具调用串起来。
from __future__ import annotations
import json
import time
import uuid
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
TRACE_DIR = ROOT / "reports" / "traces"
SENSITIVE_KEYS = {"phone", "id_card", "address", "email", "buyer_id"}
def mask(value, keys=SENSITIVE_KEYS):
if isinstance(value, dict):
return {key: ("***" if key in keys else mask(item, keys))
for key, item in value.items()}
if isinstance(value, list):
return [mask(item, keys) for item in value]
return value
class TraceRecorder:
def __init__(self, trace_id: str | None = None, echo: bool = True):
self.trace_id = trace_id or uuid.uuid4().hex[:12]
self.started = time.perf_counter()
self.echo = echo
self.spans: list[dict] = []
self.usage = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
TRACE_DIR.mkdir(parents=True, exist_ok=True)
self.path = TRACE_DIR / f"{self.trace_id}.jsonl"
def span(self, name: str, **fields) -> None:
record = {
"trace_id": self.trace_id,
"span": name,
"elapsed_ms": round((time.perf_counter() - self.started) * 1000, 2),
**mask(fields),
}
self.spans.append(record)
with self.path.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(record, ensure_ascii=False, default=str) + "\n")
if self.echo:
print(f"[trace] {json.dumps(record, ensure_ascii=False, default=str)}")
def add_usage(self, usage) -> None:
if usage is None:
return
for key in self.usage:
self.usage[key] += int(getattr(usage, key, 0) or 0)
def summary(self, **extra) -> dict:
return {
"trace_id": self.trace_id,
"steps": len(self.spans),
"elapsed_ms": round((time.perf_counter() - self.started) * 1000, 2),
**self.usage,
**extra,
}
脱敏要做的不是"删掉敏感字段",而是"在写日志之前统一过滤"。这样无论调用方传了多深的嵌套结构,mask都能覆盖到;如果只在个别位置手工删除字段,迟早会漏一处。
完成检查:reports/traces/下有按trace_id命名的文件;文件里能看到llm_call、tool_call、run_end三类步骤;人工检查确认没有明文手机号、地址等字段。
10. 包一层 HTTP 服务,把事件按顺序推给前端
目标:让其他系统能调用智能体,并让前端看到"正在查订单"这类进度。
操作位置:项目根目录,新增service/main.py。
先修改:WRITE_ENABLED默认为False;接口路径、端口和限流策略按部署环境调整。
参数与原理见对应参考。下面按顺序操作。
服务层要处理的不是智能体逻辑,而是工程边界:参数长度限制、写操作开关、会话加载与保存、异常转成状态码。这些逻辑留在服务层,agent/里的代码就能在命令行、批处理和测试里复用。
新建service/main.py。
from __future__ import annotations
from fastapi import FastAPI, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from agent.agent import Agent
from agent.memory import SessionStore
from agent.tools import build_registry
app = FastAPI(title="售后智能体服务")
sessions = SessionStore()
WRITE_ENABLED = False
MAX_STEPS = 6
class ChatRequest(BaseModel):
session_id: str = Field(min_length=1, max_length=64)
question: str = Field(min_length=1, max_length=500)
allow_write: bool = False
@app.get("/healthz")
def healthz() -> dict:
registry = build_registry()
return {"status": "ok", "tools": registry.names(), "write_enabled": WRITE_ENABLED}
@app.post("/chat")
def chat(request: ChatRequest) -> dict:
if request.allow_write and not WRITE_ENABLED:
raise HTTPException(status_code=403, detail="服务端未开启写操作")
agent = Agent(registry=build_registry(allow_write=request.allow_write and WRITE_ENABLED),
max_steps=MAX_STEPS, echo_trace=False)
result = agent.run(request.question, history=sessions.load(request.session_id))
sessions.append_turn(request.session_id, request.question, result.answer)
return {"answer": result.answer, "finished": result.finished,
"stop_reason": result.stop_reason, "tool_calls": result.tool_calls,
"usage": result.usage, "trace_id": result.trace_id}
@app.post("/chat/stream")
def chat_stream(request: ChatRequest) -> StreamingResponse:
"""把工具事件按顺序推给前端;模型本身仍是整段返回。"""
def events():
import json
agent = Agent(registry=build_registry(allow_write=False), max_steps=MAX_STEPS,
echo_trace=False)
queue: list[tuple[str, dict]] = []
result = agent.run(request.question,
history=sessions.load(request.session_id),
on_event=lambda name, payload: queue.append((name, payload)))
for name, payload in queue:
yield f"event: {name}\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n"
yield f"event: final\ndata: {result.answer}\n\n"
return StreamingResponse(events(), media_type="text/event-stream")
这里要坦白一个限制:示例中的/chat/stream把事件先收集到queue,等整轮跑完再一起发出。它解决的是"前端需要结构化进度",不是"字级实时输出"。要做到逐字流式,需要把run改成生成器,让模型调用也带stream=True,代价是整个循环要重写成可暂停的形式。选择哪种方式取决于产品对延迟的感知要求,见服务与部署。
RELOAD=1 python3 -m uvicorn service.main:app --host 127.0.0.1 --port 8000
curl -s http://127.0.0.1:8000/healthz
curl -s -X POST http://127.0.0.1:8000/chat \
-H 'Content-Type: application/json' \
-d '{"session_id":"demo-1","question":"SO20241106002 的包裹到哪了?"}'
接口参数带长度上限是必要的:question过长会直接推高每次调用的成本,session_id不限制长度会给会话文件带来风险。这两条都属于服务层硬约束,不要写成提示词里的客气请求。
完成检查:/healthz能列出工具;/chat返回answer、tool_calls和trace_id;同一session_id连续提问有上下文;写操作默认返回 403。
11. 用容器打包并做冒烟验证
目标:让服务在另一台机器上以同样的方式启动。
操作位置:项目根目录,新增requirements.txt、Dockerfile和.dockerignore。
先修改:镜像里的 Python 版本、依赖版本和端口按目标环境调整。
参数与原理见对应参考。下面按顺序操作。
新建requirements.txt。
openai==1.55.3
fastapi==0.115.6
uvicorn==0.34.0
pydantic==2.10.4
新建Dockerfile。
FROM python:3.10-slim
WORKDIR /app
COPY requirements.txt .
RUN python -m pip install --no-cache-dir -r requirements.txt
COPY agent ./agent
COPY service ./service
COPY kb ./kb
COPY data ./data
ENV PYTHONUNBUFFERED=1
EXPOSE 8000
CMD ["uvicorn", "service.main:app", "--host", "0.0.0.0", "--port", "8000"]
新建.dockerignore。
reports/
data/*.db
__pycache__/
docker build -t shop-agent:0.1.0 .
docker run --rm -p 8000:8000 -e AGENT_API_KEY=实际密钥 -e AGENT_MODEL=实际模型 shop-agent:0.1.0
容器里不要放密钥,用运行时环境变量注入;reports/不进入镜像,避免把历史轨迹和会话带到生产。构建完成后先跑/healthz,再发一条只读问题,确认工具能取到容器内的data/shop.db。
完成检查:镜像可构建可启动;/healthz正常;只读问题返回事实;容器内没有密钥文件;日志里没有明文敏感字段。
12. 上线前做一轮验收,出事后能回滚
目标:在真实流量之外把该验的都验一遍。
操作位置:测试环境与生产环境各跑一遍,记录结果。
先修改:验收用的真实问题集、灰度比例和回滚条件按业务风险设定。
参数与原理见对应参考。下面按顺序操作。
上线前至少完成四件事。第一,用真实模型跑一遍标注清单,把报告存成real_v1,和替身基线分开;第二,用真实用户问题做一次人工抽查,重点看工具选择错、参数错、该追问没追问这三类;第三,确认写操作在生产仍默认关闭,需要时用灰度开关打开并保留人工复核;第四,准备回滚开关——把WRITE_ENABLED关掉、把提示词和工具清单回退到上一版本,这两步都要能在几分钟内完成。
python3 eval.py --report-name real_v1
python3 -m unittest discover -s tests
curl -s http://127.0.0.1:8000/healthz
发布记录里写清四件事:本次上线用的提示词版本、工具清单版本、评测报告名和模型名。只记"改了提示词"而没有版本号,出问题时无法复现。
完成检查:两份评测报告可对比;测试全绿;回滚步骤演练过一次;上线记录里四个版本信息齐全。
第二部分:参数、工具和可选做法参考
环境与接口分支
返回建立环境。主线只依赖 OpenAI 兼容协议的chat.completions.create与tools字段,不依赖任何厂商私有扩展。换服务商时至少要核对三件事:是否支持tools;工具调用返回的结构是否仍带id、function.name、function.arguments;参数是 JSON 字符串还是对象。本文的coerce_arguments同时接受两种形式,就是为了兼容这个差异。
| 项目 | 主线取值 | 分支与条件 | 影响与限制 |
|---|---|---|---|
| Python | 3.10 及以上 | 3.12 也可运行 | float | None写法需要 3.10 起 |
| 官方 SDK | openai==1.55.3 |
更高版本接口基本一致 | 跨版本升级后重跑离线测试 |
| 部署框架 | fastapi + uvicorn |
也可用 Flask、Django | 换成同步框架时注意事件循环 |
| 业务库 | SQLite 文件 | 生产换成内部服务 | 只替换工具函数内部实现,接口形状不变 |
| 运行位置 | 本地或容器 | 容器需注入密钥 | 镜像内不要固化密钥 |
| 网络 | 调用时需要联网 | 离线测试不联网 | 评测与测试必须能离线跑通 |
工具设计规范:描述、参数与副作用
返回注册工具。工具是模型与世界之间的唯一通道,它的描述质量直接决定选择质量,它的参数格式直接决定校验成本。下表把主线里六个工具的共性约定集中列出。
| 工具名 | 用途与边界 | 必填参数 | 写操作 | 幂等 | 超时 | 失败返回 |
|---|---|---|---|---|---|---|
get_order |
单个订单的事实查询,不做金额判断 | order_id |
否 | 是 | 5 秒 | found: false加提示 |
list_orders |
无订单号时按买家编号列举 | buyer_id |
否 | 是 | 5 秒 | 空列表加count |
track_shipping |
只查物流,不查金额 | order_id |
否 | 是 | 5 秒 | found: false |
query_policy |
政策检索,必须带来源 | question |
否 | 是 | 5 秒 | 空hits加转人工提示 |
create_refund |
创建退款工单,先确认再执行 | order_id、reason、idempotency_key |
是 | 靠幂等键 | 5 秒 | duplicate或over_limit |
handoff_to_human |
转人工并生成工单 | summary |
是 | 是 | 5 秒 | 返回工单号 |
工具描述要写清四件事:什么时候用、什么时候不用、需要什么前置条件、返回什么形状。参数一律使用 JSON Schema(参数结构定义),根节点必须是object,必填项写进required,并把additionalProperties设为false,让多余参数立刻变成可解释的错误。
| 描述写法 | 效果 | 常见问题 |
|---|---|---|
| 明确适用条件,如"用户给出订单号时优先使用" | 减少该用不用、不该用乱用 | 写得过泛会让模型偏好单一工具 |
| 写明边界,如"只查物流,不查金额" | 减少参数猜测 | 边界重叠时模型会随机二选一 |
| 写明前置约束,如"必须先复述并取得同意" | 降低误操作风险 | 只是软约束,仍需代码闸门 |
| 说明返回形状 | 减少模型解析错误 | 描述与实现不一致时更难排查 |
模型调用参数:含义、默认与调整依据
返回对话循环。这部分参数属于chat.completions.create调用,默认值来自 OpenAI 兼容协议的通用约定,不同服务商可能自行解释。下表按主线取值核对,实际生效值以响应和日志为准。
| 参数与中文含义 | 协议默认 | 主线值 | 常见试验点 | 调整条件、影响与限制 |
|---|---|---|---|---|
model,模型名 |
无默认 | 环境变量指定 | 同族不同尺寸 | 换模型必须重跑评测;不要跨模型直接比较延迟 |
temperature,采样温度 |
1 | 0.2 | 0、0.2、0.7 | 工具场景要稳定,取低值;调高会增加参数格式漂移 |
top_p,核采样范围 |
1 | 未设置 | 与temperature二选一调 |
同时大改会让效果无法归因 |
max_tokens,输出上限 |
视服务商 | 800 | 400、800、1600 | 过小会截断答案,过大增加成本;不限制步数 |
seed,随机种子 |
未设置 | 未设置 | 复现实验可固定 | 不保证跨版本逐位一致,不能当严格复现手段 |
tools,工具清单 |
无 | 注册表导出 | 按场景裁剪子集 | 工具过多会增加选择难度;先控制在十个以内 |
tool_choice,工具选择策略 |
视服务商 | auto |
auto、指定工具、none |
强制指定工具会跳过追问,写操作不要用 |
stream,流式返回 |
false |
false |
需要逐字输出时开启 | 开启后循环需改成生成器,tool_calls分片要重组 |
timeout,请求超时 |
SDK 默认 | 30 秒 | 10、30、60 秒 | 过短会放大重试次数,过长会拖住服务线程 |
max_retries,SDK 重试次数 |
2 | 2 | 0、2、5 | 与自写重试叠加会造成重复调用,二选一 |
temperature和max_tokens是最常被随手调的两个参数。工具调用场景下前者应该低,因为参数格式的稳定性比表达多样性重要;后者不要设得太高,因为智能体的答案通常很短,真正长的是回填的工具结果。
循环控制:步数、停止条件与并发工具
返回对话循环。循环的每个出口都要有名字,否则线上只能看到"没有回答"。
| 控制项 | 主线取值 | 含义 | 调整条件与影响 |
|---|---|---|---|
max_steps |
6 | 一次对话最多几轮工具调用 | 简单查询 3 到 4 足够;过高会让打转的请求变贵 |
final_answer |
无工具调用且有文字 | 正常结束 | 需要检查空回复与截断 |
empty_answer |
无工具调用且无文字 | 异常结束 | 应作为指标监控,而不是静默重试 |
max_steps停止 |
到达上限 | 打转结束 | 回复中应说明未完成,不要假装完成 |
| 单轮工具数量 | 按返回顺序逐个执行 | 顺序执行便于追踪 | 服务商支持并行工具调用时,写操作仍建议串行 |
| 可重试错误 | 工具retryable为真时最多重试一次 |
网络与临时故障 | 写操作重试必须依赖幂等键 |
| 模型调用失败 | 最多两次 | 超时与限流 | 与 SDK 自带重试叠加时要统一口径 |
顺序执行和并行执行是两种取舍:顺序执行便于复现和限流,代价是多工具任务更慢;并行执行更快,但多个写操作同时成功时很难回滚。主线选择顺序执行,并在轨迹里保留原始顺序。
提示词结构:写什么、不写什么
返回固定系统提示词。提示词应该只写模型推不出来的信息,凡是用工具定义能表达的,就不要用自然语言重复一遍。
| 提示词段落 | 是否必要 | 作用 | 常见错误 |
|---|---|---|---|
| 角色与范围 | 必要 | 限定业务边界 | 写成万能助手,导致越权回答 |
| 事实来源规则 | 必要 | 禁止编造状态与时效 | 只说"不要编造",不说依据从哪来 |
| 缺信息时的行为 | 必要 | 强制追问 | 省略后模型倾向猜参数 |
| 写操作前置条件 | 必要 | 先确认后执行 | 只写提示词,不加代码闸门 |
| 工具失败时的说法 | 建议 | 避免假装成功 | 让模型自由发挥,容易掩盖故障 |
| 引用格式 | 检索场景必要 | 可追溯 | 格式没给例子,模型自己发明 |
| 输出结构 | 建议 | 便于用户阅读 | 规则过多会让答案变模板化 |
| 少样本示例 | 可选 | 稳定复杂格式 | 示例与工具定义冲突时更糟 |
少样本示例适合固定输出格式或复杂多步流程,不适合用来弥补工具描述不清。示例里出现工具调用时,必须保证示例中的参数和真实工具定义一致,否则会让模型模仿出错误参数。
记忆策略:窗口、摘要与长期记忆
返回管好记忆。本文使用滑动窗口加字符预算,这是最容易解释、最容易复现的方案,代价是久远信息会丢。
| 策略 | 主线是否采用 | 保存内容 | 优点 | 代价与风险 |
|---|---|---|---|---|
| 滑动窗口 | 采用,6 轮 | 最近用户与助手消息 | 实现简单、成本可控 | 久远约束会丢失 |
| 字符预算裁剪 | 采用,6000 字符 | 按总长度继续丢最旧 | 防止上下文爆掉 | 可能丢掉关键前提 |
| 摘要压缩 | 未采用 | 周期性把历史压成摘要 | 保留长期信息 | 摘要本身可能失真,需要评测 |
| 完整轨迹存档 | 采用,写日志 | 全部消息与工具结果 | 可审计、可复现 | 体积大,必须脱敏 |
| 长期记忆库 | 未采用 | 用户偏好与画像 | 跨会话个性化 | 涉及隐私与过期策略 |
裁剪时最容易踩的坑是把tool_calls和对应的tool消息拆开:助手消息说"我要调用工具",紧接着的下一条消息必须是那个工具的结果。按轮次成对裁剪可以避免这个问题;如果需要更精细的裁剪,就要显式检查配对关系。
检索:切分、打分与引用
返回加检索。主线使用字符重合打分,好处是离线可复现、没有额外依赖;缺点是同义改写命中差。
| 参数或做法 | 主线取值 | 作用 | 可选方案与条件 |
|---|---|---|---|
| 切分单位 | 标题分块,块内按句 | 保证片段语义完整 | 按固定长度切分更快但容易切断条款 |
MAX_CHARS |
300 | 单片段长度上限 | 200 到 500 是常见起点 |
| 片段重叠 | 0 | 不重复内容 | 加入重叠可减少断句丢失,代价是重复引用 |
top_k |
3 | 给模型几段依据 | 政策类 2 到 4 段通常够用 |
min_score |
3.0 | 低于此分视为未命中 | 过低会把无关条款喂给模型 |
| 打分方式 | 字符重合加标题加权 | 中文短句可用 | 向量检索适合同义改写,但需要嵌入服务 |
| 来源编号 | 文件名#序号 |
回答可追溯 | 编号必须稳定,重建索引后含义不能变 |
| 未命中行为 | 返回空并提示转人工 | 避免编造 | 不要用默认条款兜底 |
片段重叠和top_k是一对权衡:重叠提高召回但让同一句话被引用多次,模型回答里可能出现重复依据;top_k调大提高覆盖率,也会引入更多噪音,反而降低答案准确度。
评测口径:指标、清单与判定
返回评测效果。指标必须先定口径再算数,否则同一份数据能得出互相矛盾的结论。
| 指标 | 主线口径 | 计算方式 | 适用与限制 |
|---|---|---|---|
| 任务成功率 | 工具精确一致且追问行为正确 | 通过行数除以总行数 | 最严格的单一指标,适合做门槛 |
| 工具精确率 | 调用序列完全相同 | 逐行比较列表 | 顺序不同也算失败,需容忍时改口径 |
| 工具召回率 | 必要步骤覆盖比例 | 按位置匹配后求比例 | 只错顺序时召回仍高 |
| 参数准确率 | 期望键值命中比例 | 逐键比对 | 只覆盖清单里写明的键 |
| 追问正确性 | 应追问的题没有直接下结论 | 人工标注should_finish |
依赖标注质量 |
| 平均步数 | 每行工具调用次数均值 | 直接平均 | 与成本和延迟相关 |
| 延迟分位 | p50 与 p95 | 排序取分位 | 样本少时分位不稳定 |
| 空回复率 | empty_answer占比 |
按stop_reason统计 |
应单独监控 |
| 成本量级 | 按单价折算 | 令牌数乘单价 | 仅供量级参考 |
清单规模从二三十条起步就能发现大部分问题;每条要人工写期望工具和期望行为,不要用模型输出当标准答案。指标变化超过阈值时,应该先看逐任务明细,判断是真实退步还是清单本身需要修正。
可观测性:轨迹、指标与告警
返回记录轨迹。轨迹解决个案复现,指标解决整体趋势,两者缺一不可。
| 观测项 | 主线做法 | 用途 | 注意事项 |
|---|---|---|---|
trace_id |
每次运行生成短随机串 | 串联一次对话的全部步骤 | 要能回传给前端便于报障 |
llm_call步骤 |
记录模型、耗时、消息数 | 定位模型侧延迟 | 不要记录完整密钥 |
tool_call步骤 |
记录工具、成功、错误码、耗时 | 定位工具失败率 | 参数与结果需脱敏 |
run_end步骤 |
记录停止原因、步数、令牌数 | 统计成功率与成本 | 停止原因必须枚举 |
| 会话存档 | 按会话编号存 JSON | 支持多轮与回访 | 设置过期与清理策略 |
| 敏感字段 | 统一mask过滤 |
满足合规要求 | 名单要随业务更新 |
安全与合规:权限、闸门与人在环
返回只读闸门。智能体的安全边界不能只写在提示词里,必须落在代码和流程上。
| 控制点 | 主线做法 | 作用 | 补充条件 |
|---|---|---|---|
| 只读默认 | allow_write=False |
默认不能改数据 | 生产默认关闭,按需灰度 |
| 写操作白名单 | side_effect=True标记 |
集中识别写操作 | 新增写工具必须显式标记 |
| 用户确认 | 写操作前复述关键信息 | 降低误操作 | 提示词加代码双重约束 |
| 幂等键 | 写工具必填 | 重试不产生重复工单 | 服务端可覆盖模型填写值 |
| 金额与权限 | 超过阈值转人工 | 控制资金风险 | 阈值随业务调整 |
| 工具最小权限 | 每个工具只读或只写 | 限制影响范围 | 不要给一个工具混合多种权限 |
| 人工兜底 | 超范围、情绪激烈转人工 | 保住体验底线 | 转人工也要生成可追踪工单 |
| 审计 | 轨迹日志加会话存档 | 事后可追查 | 保留期限按合规要求 |
| 提示注入 | 工具结果按不可信数据处理 | 防止被外部文本指挥 | 工具结果不要当指令执行 |
工具返回的内容属于外部数据,不是指令。把抓取到的网页、用户上传的文档直接当作"模型可以执行的命令",是智能体最容易被利用的地方;凡是来自工具结果的动作,都要经过与用户输入相同的校验路径。
常用评测与观测工具(可选)
返回评测效果。主线为了离线可复现只用了标准库,实际团队可以按需引入下表工具;引入任何工具前先确认它能导出原始数据,否则结论无法复核。
| 方向 | 可选工具 | 适合解决的问题 | 引入条件 |
|---|---|---|---|
| 编排框架 | 通用智能体框架 | 工具多、流程复杂 | 先确认循环可控、异常可捕获 |
| 评测平台 | 在线评测与标注系统 | 清单规模大、需要多人标注 | 需要能导出逐任务结果 |
| 追踪系统 | 调用链追踪服务 | 跨服务定位延迟与失败 | 注意敏感信息出境问题 |
| 向量检索 | 专用向量库 | 文档量大、同义改写多 | 需要嵌入服务与索引维护 |
| 提示词管理 | 版本化提示词服务 | 多环境灰度 | 版本号要写进发布记录 |
第三部分:按现象排查问题
先保留现场,再定位出错阶段
操作位置:先看reports/traces/下对应trace_id的文件,再动手改代码。
出问题时第一件事不是改提示词,而是把这一轮的轨迹完整保存下来:llm_call步骤的数量和耗时、tool_call步骤的顺序与错误码、run_end里的stop_reason。改动之前先复制一份报告和轨迹,否则修好之后无法证明是哪个改动起了作用。
判断出错阶段的顺序建议固定成:环境与依赖、工具与参数、循环与话术、检索与引用、评测与口径、服务与性能。前面的阶段没排除,就不要去调后面的参数。
环境与依赖问题
| 现象 | 常见原因 | 检查动作 | 处理 |
|---|---|---|---|
ModuleNotFoundError: openai |
装到了别的解释器 | python3 -c "import sys; print(sys.executable)" |
用同一解释器重装并记录版本 |
| 密钥相关报错 | 环境变量未导出或名字不一致 | env | grep AGENT_ |
只核对变量名,不打印密钥内容 |
| 接口返回 401 或 403 | 密钥失效、额度或地址错误 | 用最小请求测通 | 修好再跑评测,不要带着错跑全量 |
| 接口返回 404 | AGENT_BASE_URL多了或少了路径 |
核对服务商文档 | 统一到/v1这类根路径 |
| pip 依赖冲突 | 全局环境混装 | python3 -m pip check |
建独立虚拟环境,重新固定版本 |
| 换机器后行为不同 | SDK 或模型版本不同 | 比较reports/environment.txt |
版本不一致就不比较评测结果 |
工具和参数出问题
| 现象 | 常见原因 | 检查动作 | 处理 |
|---|---|---|---|
| 模型从不调用工具 | 描述太笼统或清单没传 | 打印as_openai_tools()输出 |
描述里补适用条件,确认tools非空 |
| 总是调用同一个工具 | 工具职责重叠 | 看描述里的边界句 | 合并或明确各工具不做什么 |
unknown_tool频发 |
模型记错工具名或历史里有旧名字 | 检查轨迹里的tool字段 |
修正提示词示例与工具名,清理旧历史 |
unknown_argument频发 |
描述里提到了参数格式而未声明 | 对比描述与schema |
参数要么声明要么从描述里删掉 |
bad_json |
参数不是合法 JSON | 存下原始arguments |
保留原文并在回填里提示格式要求 |
bad_type |
模型把数字写成中文或带单位 | 看原始参数 | 收紧描述,或在校验里做显式转换 |
校验通过但报bad_arguments |
校验默认值与函数签名不一致 | 打印inspect.signature |
让default与函数默认值保持一致 |
| 写操作被拒 | 只读模式 | 看error_code是否为write_not_allowed |
确认业务同意后再开灰度开关 |
| 工具报错后模型答"已完成" | 错误回填不可读 | 检查tool消息内容 |
回填里保留ok与error_code,提示词写明失败说法 |
循环和话术出问题
| 现象 | 常见原因 | 检查动作 | 处理 |
|---|---|---|---|
max_steps触顶 |
模型反复调用同一工具 | 看轨迹里重复的工具名与参数 | 提示词加"同一工具同参数不要重复";检查工具是否真的返回了数据 |
| 消息序列被拒绝 | 丢了tool_calls或tool_call_id |
打印消息列表结构 | 助手消息回填完整调用信息,工具结果带对应编号 |
| 每次都从零开始 | 历史没接上 | 确认history非空 |
服务层加载并保存会话 |
| 上下文过长导致失败 | 历史或工具结果太大 | 统计消息总长度 | 用trim_history裁剪,工具结果加长度上限 |
empty_answer |
输出上限过小或模型返回空 | 看max_tokens与响应结构 |
提高上限,空回复记入指标监控 |
| 该追问却直接执行 | 提示词缺少缺信息规则 | 看清单里追问类任务的通过情况 | 补规则并加入评测清单 |
| 写操作未经确认 | 只有提示词约束 | 检查是否有代码闸门 | 加只读默认与确认流程 |
| 回答里暴露工具细节 | 输出结构没约束 | 抽查回答文本 | 提示词明确不要展示调用过程 |
检索和引用出问题
| 现象 | 常见原因 | 检查动作 | 处理 |
|---|---|---|---|
| 检索总是空 | min_score过高或分词不匹配 |
打印每段得分 | 降低阈值或换打分方式 |
| 命中无关条款 | min_score过低或top_k过大 |
逐段查看命中内容 | 提高阈值,加入反向示例 |
| 引用编号不存在 | 重建索引后编号变化 | 对比来源编号与文件 | 固定编号规则,重建后重跑评测 |
| 回答不带引用 | 提示词没给格式示例 | 看提示词与输出 | 给出文件名#序号示范 |
| 片段被截断在句中 | 切分长度过小 | 看片段末尾字符 | 按句切分,不要按固定字符硬切 |
| 政策更新后答案没变 | 缓存或索引未重建 | 检查加载时机 | 明确重建流程并记录版本 |
评测和口径问题
| 现象 | 常见原因 | 检查动作 | 处理 |
|---|---|---|---|
| 指标全是满分 | 用替身模型跑真实结论 | 看报告里的note字段 |
替身只用于口径验证,真实效果另跑 |
| 同一份报告前后不一致 | 清单被改动或覆盖 | 对比清单版本与报告时间 | 清单纳入版本管理,报告不覆盖 |
| 成功率突然下降 | 模型或提示词变更 | 比较两次报告逐行差异 | 三方(清单、提示词、模型)分别回退验证 |
| 参数准确率虚高 | 只比对部分键 | 看checks覆盖范围 |
补全期望键,或降低指标解释强度 |
| 延迟分位波动大 | 样本量太小 | 看任务数与分位差 | 扩大样本或改用均值加最大值 |
| 追问类任务总是失败 | 清单缺should_finish标注 |
检查清单字段 | 补齐标注,纳入门槛指标 |
服务和性能问题
| 现象 | 常见原因 | 检查动作 | 处理 |
|---|---|---|---|
| 接口偶发超时 | 模型侧延迟或工具阻塞 | 看轨迹里各步骤耗时 | 分别设置模型与工具超时 |
| 同一问题重复产生工单 | 缺少幂等控制 | 查退款表重复记录 | 必填幂等键,服务端覆盖模型值 |
| 事件推送顺序错乱 | 并发处理未保序 | 看事件到达顺序 | 顺序执行或加序号 |
| 流式接口没有逐字效果 | 事件在轮末汇总 | 对照实现说明 | 需要逐字时改写循环为生成器 |
| 会话文件越来越多 | 没有清理策略 | 统计目录文件数 | 加过期清理与归档 |
| 日志出现敏感信息 | 脱敏名单未覆盖 | 抽样检查轨迹 | 扩充SENSITIVE_KEYS,回溯清理旧文件 |
| 容器启动即退出 | 缺少依赖或环境变量 | 看容器日志 | 补齐依赖与注入变量,先跑/healthz |
本文检查范围与资料依据
本文所有代码示例都在同一份工程里逐段实现并实际运行过,验证范围包括:参数校验的六类错误码、注册表的三类失败与只读闸门、对话循环的四个出口、事件回调顺序、记忆裁剪的轮次与字符预算、检索来源标注与片段长度、容器与依赖文件的可构建性,以及评测脚本的逐任务判定。
验证结果只有一个口径结论:reports/eval_baseline.json中的指标全为满分,说明清单、判定与指标计算这条链路是通的,不代表真实模型效果。真实模型必须用同一份清单另跑报告再比较。本文不宣称任何模型在真实业务上的成功率、延迟或成本表现。
协议事实以 OpenAI 兼容接口的tools与chat.completions行为为准;版本与参数默认值以reports/environment.txt记录的依赖版本为准。可选方案(编排框架、向量检索、追踪系统、提示词服务)只做方向说明,未在本工程中实测,采用前需按自身数据重新验证。
浙公网安备 33010602011771号