032AI Agent阅读记录
第 032 卷

从零开始 AI Agent 实战(三):工具调用循环,从会聊天到能查工单

实现第一个可控的 Agent 循环:模型选择只读工具,服务端校验参数并注入真实结果,超过步数或返回非法 JSON 时安全退出。

乌漆嘛黑和 Ahri
第 032 期

工具调用循环:从会聊天到能查工单

先看模型闯祸

给模型一句“查一下我的工单”,它可能返回:

{"tool":"get_ticket","ticket_id":"随便猜一个"}

或者把工具结果直接写进回答:

你的工单 TICKET-000042 已解决,处理人是 Alice。

后者没有任何工具调用,完全可能是编造。前者的字段名也不是 OpenAI-compatible API 约定的 tool_calls。因此工具调用不是“把几个函数描述塞进 prompt”,而是一套严格的协议循环。

Preview
工具调用循环的状态流转

工具契约先写成 Schema

TOOLS = [{
    'type': 'function',
    'function': {
        'name': 'get_ticket',
        'description': '按工单 ID 查询当前用户有权查看的工单',
        'parameters': {
            'type': 'object',
            'properties': {'ticket_id': {'type': 'string', 'pattern': '^TICKET-[0-9]{6}$'}},
            'required': ['ticket_id'],
            'additionalProperties': False,
        },
    },
}, {
    'type': 'function',
    'function': {
        'name': 'search_kb',
        'description': '检索知识库,返回相关片段及其来源。知识库为空时返回空列表。',
        'parameters': {
            'type': 'object',
            'properties': {
                'query': {'type': 'string', 'minLength': 2, 'maxLength': 200},
                'top_k': {'type': 'integer', 'minimum': 1, 'maximum': 10, 'default': 5},
            },
            'required': ['query'],
            'additionalProperties': False,
        },
    },
}]

Schema 有三个工程价值:模型知道参数形状;服务端可以拒绝多余字段;评测可以判断「工具选对且参数对」。不要在工具描述里写「如果用户是管理员就返回所有工单」,权限是代码的责任。

search_kb 现在返回空列表——语料要到第 9、10 篇才有。先把契约固定下来是有意的:第 3 篇定义的这个函数签名,到第 11 篇接上混合召回、第 12 篇接上引用时都不用改,变的只有实现。这和第 1 篇的 Repository 是同一个手法。

它也带来本篇第二个坏结果演示:知识库是空的,但模型照样会编。给它「点图标没反应怎么办」,它会跳过工具直接答一段听起来很像样的排查步骤。工具返回空,不等于模型会承认自己不知道——服务端怎么把「有没有答案」变成可计算的量,是第 12 篇的主题。

注册表和最小循环

REGISTRY = {'get_ticket': get_ticket, 'list_tickets': list_tickets, 'search_kb': search_kb}

async def run_agent(messages, provider, user, max_steps=6):
    for step in range(max_steps):
        response = await provider.complete(messages, tools=TOOLS)
        assistant = response.message
        messages.append(assistant.model_dump())
        calls = assistant.tool_calls or []
        if not calls:
            return AgentResult(text=assistant.content or '', steps=step + 1)

        for call in calls:
            fn = REGISTRY.get(call.function.name)
            if fn is None:
                messages.append(tool_error(call.id, 'unknown_tool'))
                continue
            try:
                args = json.loads(call.function.arguments)
                validate_schema(fn.__name__, args)
                result = await fn(user=user, **args)
                messages.append(tool_message(call.id, result))
            except json.JSONDecodeError:
                messages.append(tool_error(call.id, 'invalid_json'))
            except PermissionError:
                messages.append(tool_error(call.id, 'forbidden'))
            except ValueError as exc:
                messages.append(tool_error(call.id, 'invalid_arguments', str(exc)))
    return AgentResult(text='我暂时无法完成这个查询,请转人工处理。', stopped='max_steps')

关键点是工具结果由服务端追加到 messages,模型不能自己伪造 tool 角色消息。每一步都要有上限;max_steps=6 是防止模型在两个工具之间来回跳转的最后保险。

Preview
消息数组在工具调用中的增长

非法 JSON 不能让请求 500

先写一个会返回坏参数的 Mock:

MockResponse('{"ticket_id": "TICKET-000042",}')

多出来的那个逗号足以让 json.loads 抛异常。真实模型输出这种东西的频率比想象中高得多——尤其在参数里含中文、换行或引号时。处理方式不是加固解析器,而是给它一条正常的失败分支:

Preview
坏 JSON 走容错分支,错误回注给模型重试

期望不是 500,而是把结构化错误反馈给模型,让它有机会修正:

{
  "role": "tool",
  "tool_call_id": "call_1",
  "content": "{\"ok\":false,\"error\":{\"code\":\"invalid_json\",\"retryable\":true}}"
}

最多允许一次参数修正;第二次仍失败就终止并记录审计。不要把 Python traceback 放进模型上下文,它既泄露实现细节,也无法帮助模型选择下一步。

当前用户先用桩,但不能信任模型

本篇为了聚焦循环,user 使用固定的 demo-user。工具仍然接收这个参数,并在 Repository 查询时过滤 owner:

async def get_ticket(*, user: User, ticket_id: str):
    ticket = await repo.get(ticket_id)
    if not ticket or ticket.reporter_id != user.id:
        raise PermissionError('ticket_not_visible')
    return ticket.to_public_dict()

第 6 篇会把桩替换为 JWT 和 RBAC,但工具接口不变,这就是先固定边界再替换实现的好处。

测试与排障

uv run pytest tests/agent/test_tool_loop.py -q
uv run pytest tests/agent/test_tool_loop.py -k max_steps -vv

至少覆盖:无工具调用、一次调用、多个调用、未知工具、非法 JSON、schema 多余字段、权限拒绝、超过最大步数。调试时打印 tool_namecall_id、耗时和错误码,默认不要打印完整用户问题或工具参数中的密钥。

本篇验收标准

  1. 「查工单」会产生真实的 tool 消息,回答中包含数据库返回的标题。
  2. 模型伪造的 tool 结果不会被服务端接受。
  3. 非法 JSON 最多触发一次修正,最终返回可读的降级答案。
  4. 任意输入都不会执行未注册函数;循环最多 6 步。
  5. search_kb 返回空列表时,模型仍可能编造答案——把这个行为记录下来,它是第 12 篇拒答阈值的起点。

下一篇先停下来测量:我们要建立一套评测基线,否则后面每次改 prompt 都只能凭感觉。