直接把多智能体示例的代码拉下来改,最先撞上的往往不是“跑不通”,而是“报错了不知道是谁的错”:模型没返回工具调用、工具参数解析失败、工具执行抛异常、编排层把消息顺序拼错,这四类问题最后都表现为一句失败日志。更稳的接入顺序是四步递进:单轮无工具对话 → 单只读工具闭环 → 双工具选择 → 拆两个角色。每一步只引入一个新变量,并各自有验证点和回退点,出错时就能把范围压回上一层。
建议按“单轮对话 → 单只读工具 → 双工具 → 双角色”四步接入,每步只新增一个变量:先确认模型服务可用,再确认工具调用闭环完整,再确认多工具选择正确,最后才引入角色分工。验证点分别落在返回形态、回填消息、选择结果和转交次数上。任一步失败,先回退到上一步确认仍可复现,再定位是模型输出、解析、执行还是编排层的问题,不要在多角色配置里改单工具的参数。
第一步:单轮无工具对话先跑通
这一步只确认模型服务本身可用:网络能通、鉴权有效、模型名存在、返回是正常补全而不是错误页。此时不要注册任何工具,也不要在 system 里提工具。
POST {BASE_URL}/chat/completions
Authorization: Bearer $API_KEY
Content-Type: application/json
{
"model": "your-model-name",
"messages": [{"role": "user", "content": "用一句话说明你是谁"}],
"stream": false
}
期望返回形态:choices[0].message.content 是非空字符串,message 里不出现 tool_calls,finish_reason 是正常结束值。如果 content 为空但也没报错,先怀疑模型名或请求体被上游改写,而不是去动工具注册。
服务端日志里该出现的记录:一条入站请求(路径、模型名、消息条数)、一条出站状态码、一条耗时或 token 用量。连入站都没有,问题在网络或鉴权;有入站没有出站,问题在模型服务或超时配置。用同一请求连发几次都能稳定拿到非空 content,才算这步通过。这一步失败时没有更上层可退,先把模型服务和鉴权单独确认,不要继续往下写工具。
第二步:挂一个只读工具,看调用闭环
只挂一个只读工具,比如查询当前时间或按 id 查一条记录。只读的好处是执行失败不改坏数据、重复调用也安全,便于反复验证。
tools = [{
"type": "function",
"function": {
"name": "get_time",
"description": "返回指定时区的当前时间。用户询问时间、日期时使用。",
"parameters": {
"type": "object",
"properties": {
"tz": {"type": "string", "description": "时区名,例如 Asia/Shanghai"}
},
"required": ["tz"]
}
}
}]
# 请求体里加上 "tools": tools 和 "tool_choice": "auto"
调用返回结构:模型不再直接给答案,而是返回 choices[0].message.tool_calls,数组每项有 id、type、function.name、function.arguments;arguments 通常是 JSON 字符串,需要自己解析。同一轮可能有多个 tool_calls,先按数组遍历,不要只取第 0 个。
msg = resp["choices"][0]["message"]
# 1) 原样带 tool_calls 追加 assistant 消息
messages.append({
"role": "assistant",
"content": msg.get("content"),
"tool_calls": msg["tool_calls"],
})
# 2) 每个调用执行后,追加一条 role=tool 的消息
for call in msg["tool_calls"]:
args = json.loads(call["function"]["arguments"])
result = run_tool(call["function"]["name"], args)
messages.append({
"role": "tool",
"tool_call_id": call["id"],
"content": json.dumps(result, ensure_ascii=False),
})
# 3) 用追加后的 messages 再请求一次
resp2 = chat(messages, tools=tools)
验证点:工具被调用一次、执行日志里有入参与返回值、第二轮返回自然语言答案而不是空 content。回退点:如果第二轮又返回 tool_calls,先看 tool_call_id 是否和 call["id"] 一致——对不上时,不少服务会认为工具结果未提供,从而反复要求调用。
第三步:加第二个工具,观察选择行为
两个工具并存时,真正的风险是选错或两个都不选。第二个工具要和第一个在适用边界上分开,描述里写清何时用、何时不用。
# 工具 A:面向模糊、自然的问法
{"name": "search_orders",
"description": "按关键词或时间范围模糊查询订单列表。用户只给出模糊条件时使用;已给出精确订单号时不要使用。"}
# 工具 B:面向精确标识
{"name": "get_order_by_id",
"description": "按订单号精确查询单条订单详情。仅在用户给出完整订单号时使用。"}
描述写法的差异点在触发条件,而不在功能罗列:把“何时不用”也写进 description,通常比只写“这是干什么的”更能减少误选;参数描述同样要写清格式。
记录和比对方式:每次请求后把被选中的 function.name 和解析后的参数追加成一行结构化日志,例如 {"turn": n, "chosen": "get_order_by_id", "args": {...}, "expected": "get_order_by_id"}。准备一小组固定问法(模糊条件、精确单号、两者都缺),跑完逐条比对 chosen 与 expected。验证点是被选中工具与预期一致;回退点是两个工具频繁混淆时,先撤掉第二个,确认单工具仍稳定,再改描述重试。
第四步:拆成两个角色并界定各自工具集
到这一步再引入多智能体:把角色当成一个配置项,而不是另起一套代码路径。两个角色各带独立 system 提示词和工具白名单,编排层只负责在两者之间转交消息。
ROLES = {
"reception": {
"system": "你负责理解用户意图并给出最终答复。只有需要精确订单信息时,才转交给 order_agent,不要自己猜测订单状态。",
"tools": [],
"can_handoff_to": ["order_agent"],
},
"order_agent": {
"system": "你只处理订单查询,输入应包含订单号或查询条件。查询完成后把结果返回给 reception,不要直接面向用户解释。",
"tools": ["get_order_by_id", "search_orders"],
"can_handoff_to": ["reception"],
},
}
# 每次执行前按白名单过滤工具
def build_tools(role):
return [t for t in ALL_TOOLS
if t["function"]["name"] in ROLES[role]["tools"]]
白名单的作用是把“选错工具”的影响限制在单个角色内:reception 拿不到订单工具,就不会在自我对话里乱查;order_agent 拿不到无关工具,输出也更容易解析。
转交次数的记录方式:在轮次循环里维护 turn 与 handoff_count,每发生一次角色切换就写一条 {"from": "reception", "to": "order_agent", "turn": n, "reason": "..."},并设一个上限(例如 max_handoff),达到上限就让当前角色直接输出。验证点是日志里出现符合预期的转交路径且次数在上限内;回退点是转交次数异常升高时,先退回第三步的单角色双工具形态,确认选择行为正常,再检查角色提示词里的转交条件是否写得太宽。
每一步失败时的回退判断
把排查顺序固定成四层:模型输出 → 解析 → 执行 → 编排。前一层没确认干净,不要跳到后一层。
- 模型输出层:看原始返回里有没有
tool_calls、finish_reason是什么、content 是否为空。观察点是原始响应体,不是封装后打印的对象。没拿到 tool_calls,问题多在提示词、工具描述或该模型对工具调用的支持程度。 - 解析层:看
function.arguments能否解析成功、字段名和类型是否与 schema 一致。字符串截断、中文引号、多层嵌套 JSON 都会在这一层暴露,观察点是解析前后的原始字符串与字典。 - 执行层:看工具函数有没有抛异常、返回值是否可序列化、重试是否安全。观察点是工具函数的入口参数和出口返回值。
- 编排层:看 messages 顺序是否为 assistant(tool_calls) 紧跟对应的 role=tool、
tool_call_id是否一一对应、多角色消息有没有串进同一个上下文。观察点是把整个 messages 数组打印出来逐条核对。
回退动作按现象判断:模型根本不返回工具调用,退回第一步确认模型服务与模型名,别改工具表;参数解析失败,退回第二步用最简单的一个参数工具复现;执行抛错,停掉第二个工具,按第三步之前的单工具形态验证;编排出错(消息拼错、转交后上下文丢失),退回第三步的单角色形态,确认多轮消息仍正确后再拆角色。每一步都留一份正常样本(一次成功的请求、返回和日志),排错时先拿它对比差异,通常比从头读代码更快定位到是哪一层变了。