技术·Agent From Zero:从一次模型调用到可靠 Agent · 第 6 篇·2026-09-17·15 分钟

第五篇:模型提出调用,程序执行工具

上一篇,我们给 get_inventory() 写了 Tool Schema,也验证了 API 能接收它、模型能理解它。但那时模型只看到了工具的“说明书”:程序没有执行函数,也没有把查询结果交还给模型。

这一篇要第一次走完工具调用的全过程:

模型看到 Schema → 提出调用请求 → 程序执行函数 → 程序送回结果 → 模型根据结果回答用户。

一次工具调用包含两次模型请求
一次工具调用包含两次模型请求

这还不是 Agent Loop,也就是让模型反复判断是否还要调用工具的循环。本篇只处理“最多一次工具调用”:模型要么直接回答,要么请求 get_inventory,程序执行后再让模型生成最终回答。连续调用多个工具,是后面才会解决的问题。

文章对应代码仓库 agent-from-zero 的 v0.1.2-single-tool-call。可以切换到这个 tag,对照 src/agent/agent.py、src/agent/llm.py 与 tests/test_agent.py 阅读。

先复用已有的请求和统计代码

之前的 chat() 同时负责发送请求和生成 ChatResult。加入工具调用后,程序会用另一套流程请求模型。如果直接复制整段代码,异常处理、延迟统计和成本估算就会出现两份实现。这里先把这两部分拆开:

python
def _complete(messages, temperature=0.0, tools=None):
    started_at = time.perf_counter()
    try:
        kwargs = dict(model=DEFAULT_MODEL, temperature=temperature, messages=messages)
        if tools:
            kwargs["tools"] = tools
        response = get_client().chat.completions.create(**kwargs)
    except AuthenticationError as e:
        raise AuthenticationFailure(str(e)) from e
    except APIConnectionError as e:
        raise NetworkFailure(str(e)) from e
    except BadRequestError as e:
        raise BadRequestFailure(str(e)) from e
    latency_ms = (time.perf_counter() - started_at) * 1000
    return response, latency_ms


def _to_chat_result(response, latency_ms) -> ChatResult:
    usage = response.usage
    cache_hit_tokens = usage.prompt_cache_hit_tokens or 0
    cache_miss_tokens = usage.prompt_cache_miss_tokens or 0
    tokens_out = usage.completion_tokens
    return ChatResult(
        text=response.choices[0].message.content,
        tokens_in=usage.prompt_tokens,
        tokens_out=tokens_out,
        cache_hit_tokens=cache_hit_tokens,
        cache_miss_tokens=cache_miss_tokens,
        latency_ms=latency_ms,
        estimated_cost_usd=_estimate_cost_usd(cache_hit_tokens, cache_miss_tokens, tokens_out),
    )

_complete() 负责请求模型并测量延迟,_to_chat_result() 负责提取文本、Token 和估算成本。原来的 chat() 只需组合它们,对外行为不变:

python
def chat(user_message, system=DEFAULT_SYSTEM, temperature=0.0) -> ChatResult:
    messages = [
        {"role": "system", "content": system},
        {"role": "user", "content": user_message},
    ]
    response, latency_ms = _complete(messages, temperature=temperature)
    return _to_chat_result(response, latency_ms)

这样,工具调用就能直接复用前面写好的异常处理和统计代码。

用 `ask()` 完成一次工具调用

新的 ask() 函数写在 src/agent/agent.py:

python
TOOLS = [GET_INVENTORY_SCHEMA]
TOOL_FUNCTIONS = {"get_inventory": get_inventory}


def ask(user_message: str, system: str = llm.DEFAULT_SYSTEM) -> ChatResult:
    messages = [
        {"role": "system", "content": system},
        {"role": "user", "content": user_message},
    ]

    response, latency_ms = llm._complete(messages, tools=TOOLS)
    first_result = llm._to_chat_result(response, latency_ms)

    message = response.choices[0].message
    if not message.tool_calls:
        return first_result

    tool_call = message.tool_calls[0]
    args = json.loads(tool_call.function.arguments)
    tool_result = TOOL_FUNCTIONS[tool_call.function.name](**args)

    messages.append({
        "role": "assistant",
        "content": message.content,
        "tool_calls": [{
            "id": tool_call.id,
            "type": "function",
            "function": {
                "name": tool_call.function.name,
                "arguments": tool_call.function.arguments,
            },
        }],
    })
    messages.append({
        "role": "tool",
        "tool_call_id": tool_call.id,
        "content": json.dumps(tool_result),
    })

    response, latency_ms = llm._complete(messages)
    second_result = llm._to_chat_result(response, latency_ms)
    return _sum_chat_results(first_result, second_result)

顺着代码读,ask() 只有两个分支:

  • —没有 tool_calls:模型已经直接回答,返回第一次请求的结果。
  • —有 tool_calls:程序解析参数、执行函数,把调用消息和工具结果加入对话记录,再请求一次模型生成最终回答。

三条消息怎样对上号

这一段协议最容易混淆的不是函数调用本身,而是三份数据之间的关系:

Tool Call 与工具结果通过 ID 关联
Tool Call 与工具结果通过 ID 关联

需要留意以下细节:

  1. 1.tool_call.function.arguments 是 JSON 字符串,因此要先用 json.loads() 转成 Python 字典。
  2. 2.第二次请求要带回模型生成的 assistant 消息,其中保留 tool_calls[].id、函数名和原始参数。
  3. 3.工具结果是一条 role: "tool" 消息。它的 tool_call_id 必须与调用请求的 id 相同。这一版把工具结果作为文本放进 content,因此用 json.dumps() 把 Python 字典转换成 JSON 字符串。这个转换过程也叫“序列化”。

本篇手动重建了纯字典形式的 assistant 消息,而没有把 SDK 对象直接放回 messages。这样对话记录可以直接转换成 JSON。以后无论要保存完整的调用记录,还是保存程序运行到哪一步,都不需要依赖某个 SDK 对象的内部结构。

第二次 _complete(messages) 没有再传 tools。这是本篇为了“最多调用一次工具”有意做出的限制,并不是 Tool Calling 协议要求这样写。程序预期第二次请求直接生成最终回答,也没有准备处理新的工具调用。真正的 Agent Loop 会在后续请求中继续提供工具,并检查模型是否还要调用。

模型提出调用,程序负责执行

“Tool Calling”这个名字容易让人以为模型会直接运行 get_inventory()。在本篇采用的自定义函数调用流程里,模型只生成调用请求,应用程序负责执行函数:

python
tool_result = TOOL_FUNCTIONS[tool_call.function.name](**args)
模型与程序在工具调用中的分工
模型与程序在工具调用中的分工

模型根据 Schema 给出函数名与参数;程序收到请求后,决定是否接受、如何校验、调用哪个本地函数,并拿到真实结果。DeepSeek 的官方示例也采用相同分工。

这样的分工非常重要。模型生成的参数只是一个建议,不能直接当作已经确认无误的指令。今天的工具只是读取内存中的库存;以后接入订单、支付或数据库时,程序必须先检查权限和参数,并记录执行过程,然后才能操作这些系统。

查看实际发送的两次请求

给 HTTP 客户端加一个函数,在每次请求发出时把内容记录下来,就能看到 ask("A100 还有多少库存?") 实际发送了什么:

python
captured = []

def log_request(request):
    captured.append(json.loads(request.content))

llm._client = OpenAI(
    api_key=os.environ["DEEPSEEK_API_KEY"],
    base_url=llm.DEEPSEEK_BASE_URL,
    http_client=httpx2.Client(event_hooks={"request": [log_request]}),
)

result = ask("A100 还有多少库存?")

第一次请求带上用户问题和 Tool Schema(2026-09-20):

json
{
  "messages": [
    {"role": "system", "content": "You are a helpful assistant for a small industrial equipment company."},
    {"role": "user", "content": "A100 还有多少库存?"}
  ],
  "model": "deepseek-chat",
  "temperature": 0.0,
  "tools": [{
    "type": "function",
    "function": {
      "name": "get_inventory",
      "description": "Look up the current stock level and unit price for a product, given its product ID. Use this whenever the user asks about stock, availability, or price for a specific product.",
      "parameters": {
        "type": "object",
        "properties": {
          "product_id": {
            "type": "string",
            "description": "The product ID, e.g. 'A100' or 'B200'."
          }
        },
        "required": ["product_id"]
      }
    }
  }]
}

模型没有直接回答库存,而是返回一个调用请求:

json
{
  "choices": [{
    "finish_reason": "tool_calls",
    "message": {
      "content": "",
      "role": "assistant",
      "tool_calls": [{
        "id": "call_00_bej7uwqVwn3nm4gNtEQg4078",
        "function": {
          "name": "get_inventory",
          "arguments": "{\"product_id\": \"A100\"}"
        },
        "type": "function"
      }]
    }
  }],
  "model": "deepseek-flash",
  "usage": {
    "completion_tokens": 40,
    "prompt_tokens": 335,
    "prompt_cache_hit_tokens": 128,
    "prompt_cache_miss_tokens": 207
  }
}

程序执行 get_inventory("A100") 后,第二次请求会多出 assistant 调用消息和工具结果。

先说明一个记录上的细节:下面的第一次响应和第二次请求摘自两次独立运行,所以生成的 call_... 不同。这两段只用于展示消息结构,不能拼成同一次完整过程。在同一次真实运行中,第二次请求里的 tool_calls[].id 和 tool_call_id 必须使用第一次响应返回的同一个值。

json
{
  "messages": [
    {"role": "system", "content": "You are a helpful assistant for a small industrial equipment company."},
    {"role": "user", "content": "A100 还有多少库存?"},
    {
      "role": "assistant",
      "content": "",
      "tool_calls": [{
        "id": "call_00_luvHOu8Y1L4ze41twVA08026",
        "type": "function",
        "function": {
          "name": "get_inventory",
          "arguments": "{\"product_id\": \"A100\"}"
        }
      }]
    },
    {
      "role": "tool",
      "tool_call_id": "call_00_luvHOu8Y1L4ze41twVA08026",
      "content": "{\"name\": \"Industrial Sensor A100\", \"price\": 100, \"stock\": 20}"
    }
  ],
  "model": "deepseek-chat",
  "temperature": 0.0
}

从这些实际请求和响应中还可以看到:

  • —第一次响应的 content 是空字符串,真正有意义的内容在 tool_calls 中。
  • —role: "tool" 的 content 是本地函数真实返回值序列化后的字符串,不是模型生成的库存数据。
  • —这次请求使用 deepseek-chat,响应中记录的模型名称是 deepseek-flash;335 个输入 Token 中有 128 个命中缓存。这只是这一次实验看到的情况,不代表服务端以后每次都会选择同一个模型,也不代表每次都会命中相同数量的缓存。

一个问题用工具,一个问题不用

现在用两类问题观察模型是否会采用不同的处理方式:

python
r1 = ask("A100 还有多少库存?")
print(r1.text)

r2 = ask("What is 2+2?")
print(r2.text)

真实调用结果(deepseek-chat,2026-09-19):

text
--- "A100 还有多少库存?" ---
A100(工业传感器 A100)目前还有 20 件库存。
tokens_in=431 tokens_out=57 latency_ms=1668 cost_usd=0.000099

--- "What is 2+2?" ---
2 + 2 = 4.
(只发生了一次请求)

第一个问题调用了工具,其中“20”来自 products["A100"]["stock"];第二个问题由模型直接回答,只发生了一次请求。这次结果与 Schema 中“在用户询问库存、可用性或价格时使用”的描述一致。不过,一次实验不能证明模型以后每次都会做出同样选择,还需要重复实验和自动化测试来继续验证。

汇总两次请求的成本与延迟

触发工具后,一次用户问答包含两次模型请求。用户只看到最终文本,但模型侧的 Token、费用和请求时间来自两次请求:

python
def _sum_chat_results(first: ChatResult, second: ChatResult) -> ChatResult:
    return ChatResult(
        text=second.text,
        tokens_in=first.tokens_in + second.tokens_in,
        tokens_out=first.tokens_out + second.tokens_out,
        cache_hit_tokens=first.cache_hit_tokens + second.cache_hit_tokens,
        cache_miss_tokens=first.cache_miss_tokens + second.cache_miss_tokens,
        latency_ms=first.latency_ms + second.latency_ms,
        estimated_cost_usd=first.estimated_cost_usd + second.estimated_cost_usd,
    )

如果只记录第二次请求,模型调用的成本和延迟都会被低估。这是第三篇介绍的统计方法第一次用在包含多次请求的问答中。

这里也要看清统计边界:latency_ms 累加的是两次模型请求的时间,不包含 get_inventory() 的执行时间;estimated_cost_usd 估算的也是模型费用,不包含外部数据库或第三方服务可能产生的费用。当前工具只是读取内存字典,差别很小;接入真实系统后,工具自身的耗时和费用也需要单独记录。

测试程序流程,不要固定模型的回答

bash
pytest -v

当时的实际结果:

text
tests/test_agent.py::test_ask_returns_directly_when_model_does_not_call_a_tool PASSED
tests/test_agent.py::test_ask_sends_the_tool_schema_on_the_first_call PASSED
tests/test_agent.py::test_ask_calls_get_inventory_and_returns_the_real_stock_number PASSED
tests/test_agent.py::test_ask_feeds_the_real_tool_result_back_to_the_model PASSED
tests/test_agent.py::test_ask_sums_cost_and_latency_across_both_calls PASSED
...(加上此前的 17 个)

22 passed in 0.44s

这些测试检查的是可以稳定验证的程序行为:是否发送 Schema、是否发起两次请求、tool_call_id 是否正确对应、程序是否送回真实结果,以及是否汇总两次请求的数据。

模型回答“A100 还有 20 件库存”,也可能回答“当前库存是 20 件”。两句话表达的事实相同,只是说法不同。因此,测试不应该要求模型每次说出完全相同的一句话,也不应该把某次联网调用的完整回答写死在测试代码里。

留给你的三个实验

  1. 1.把 Tool Schema 的 description 临时改成含糊的 "Get inventory.",重复运行库存问题和 What is 2+2?,记录工具选择是否变化。不要只跑一次就下结论。
  2. 2.设法让模型把商品名而不是商品 ID 填入 product_id,观察 get_inventory() 返回 None 后模型怎样回应。这里只观察,下一篇再处理缺失数据与幻觉。
  3. 3.输入“A100 和 B200 各有多少库存?”,观察只读取 message.tool_calls[0] 的实现会丢掉什么。再思考为什么真正的 Agent 需要循环。

小结

本篇第一次走完了工具调用的全过程:模型根据 Schema 提出请求,程序解析参数并执行函数,再用关联 ID 把真实结果交给模型。这个过程有两个容易忽略的事实:

  • —在自定义函数调用中,模型只提出调用;是否执行、如何执行,都由程序决定。
  • —一次工具问答可能包含多次模型请求,模型调用的成本、Token 和延迟必须合并统计;工具自身的消耗需要另行记录。

现在 ask("A100 还有多少库存?") 已经能根据真实数据回答问题,但程序仍有明显缺口:参数错误或商品不存在时,工具可能返回 None。下一篇会专门制造这种情况,观察模型会诚实说明“没有数据”,还是生成一个没有依据的答案。

参考资料

《Agent From Zero:从一次模型调用到可靠 Agent》合集 · 第 6 / 8 篇