从零开始构建售后智能体:从工具注册到上线评测

目录

从零开始构建售后智能体:从工具注册到上线评测


📚 系列文章导航

本系列围绕企业知识助手、售后智能体和仓储视觉识别,介绍从数据准备、模型开发到部署评测的实践流程,并提供配套排障指南。有修订版的文章,建议优先阅读修订版。

一、企业知识助手

从业务资料整理开始,逐步完成问答数据构建、模型微调、权重合并、量化与本地部署。

👉 从零开始构建企业知识助手:业务资料整理、模型微调与本地部署(修订版)

二、售后智能体

围绕售后业务,学习工具注册、智能体构建以及上线评测。

👉 从零开始构建售后智能体:从工具注册到上线评测

三、仓储视觉识别

从图像标注开始,逐步完成视觉模型训练、导出与部署。

👉 从零开始构建仓储视觉识别系统:从图像标注到模型部署(修订版)

四、模型开发排障

遇到环境、依赖、训练、推理、量化或部署问题时,可以按故障所在环节查阅。

👉 模型开发实用排障指南:从 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.createtools字段,不依赖某家厂商的私有扩展。换成别家兼容服务时先看环境与接口分支,不要假定工具调用格式完全一致。

完成检查:目录齐全;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.pyagent/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.jsonleval.py

先修改:清单里的问题、期望工具和是否应结束都要人工标注;不要用模型自动生成答案当标准答案。

参数与原理见对应参考。下面按顺序操作。

评测的第一步是定口径。同一句"任务成功率"在不同团队里可以指完全不同的东西,所以本文把它拆成四个可分别计算的指标:工具选择是否精确一致、工具召回是否漏掉必要步骤、参数是否正确、需要追问时是否真的问了。这些指标都能从运行轨迹里算出来,不依赖主观打分。

建立标注清单

新建data/golden_tasks.jsonl,一行一个任务。should_finishfalse表示这题的正确行为是追问或等待确认,不是直接给结论。

{"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_calltool_callrun_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返回answertool_callstrace_id;同一session_id连续提问有上下文;写操作默认返回 403。

11. 用容器打包并做冒烟验证

目标:让服务在另一台机器上以同样的方式启动。

操作位置:项目根目录,新增requirements.txtDockerfile.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.createtools字段,不依赖任何厂商私有扩展。换服务商时至少要核对三件事:是否支持tools;工具调用返回的结构是否仍带idfunction.namefunction.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_idreasonidempotency_key 靠幂等键 5 秒 duplicateover_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 与自写重试叠加会造成重复调用,二选一

temperaturemax_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消息内容 回填里保留okerror_code,提示词写明失败说法

循环和话术出问题

现象 常见原因 检查动作 处理
max_steps触顶 模型反复调用同一工具 看轨迹里重复的工具名与参数 提示词加"同一工具同参数不要重复";检查工具是否真的返回了数据
消息序列被拒绝 丢了tool_callstool_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 兼容接口的toolschat.completions行为为准;版本与参数默认值以reports/environment.txt记录的依赖版本为准。可选方案(编排框架、向量检索、追踪系统、提示词服务)只做方向说明,未在本工程中实测,采用前需按自身数据重新验证。

posted @ 2026-09-18 15:58  ai学习123  阅读(3)  评论(0)    收藏  举报