AI 工具调用是如何运作的(40 行原生 JavaScript 代码)
How AI tool calling works (40 lines of vanilla JavaScript)

原始链接: https://buttercup.sh/lessons/2026-09-15-lesson-2-tool-calling.html

要将聊天模型转变为智能体(Agent),你必须实现一个简单的四步循环,使模型能够与外界进行交互。 **循环过程:** 1. **消息(Messages):** 将对话历史记录和可用工具定义的列表发送给模型。 2. **模型(Model):** 模型决定是提供文本回复还是发出 `tool_use` 请求(包含函数名称和参数的结构化代码块)。 3. **执行(Execution):** 你的代码执行所请求的函数并捕获结果。 4. **结果(Result):** 将 `tool_result` 追加到对话历史中,并将其发回给模型。 此循环会一直重复,直到模型以纯文本形式结束对话。 **关键原则:** * **无状态(Statelessness):** 模型本身没有记忆;对话数组即是状态。你必须在每一轮重新发送完整的历史记录,因此管理工具输出的大小对于控制成本至关重要。 * **协议(Protocol):** 你必须返回每一个工具的执行结果(即使是错误),并带上正确的 `tool_use_id` 以保持链路完整。 * **工具描述(Tool Descriptions):** 模型将工具描述视为提示词。如果模型误用了工具,应优化工具描述,而不是去寻找代码中的错误。 * **安全性(Safety):** 务必限制循环次数(例如限制在 20 轮内),以防止无限循环和失控的成本支出。

抱歉。
相关文章

原文

Twenty lines of code turn a chat model into an agent. You'll have those twenty lines running today, in JavaScript and in Python, and you'll know what every one of them does. A model can't check the weather, read a file, or send an email. It can only write text. Tool calling is the agreement that turns some of that text into an instruction you agree to carry out. That agreement is the whole of agents. Everything later in this course refines it. No prior agent code assumed, and the exercise runs in the browser tab next to this one.

The same lesson, walked through end to end: the loop written by hand, one trip round the ring, and what a mismatched tool_use_id looks like when it fails. Prefer to read? Everything in the video is below, and the code blocks are copyable. Watch on YouTube.

The one idea

Beginners arrive expecting something clever. It's flatter than that: the model never runs anything. You hand it a list of functions it's allowed to ask for. When it wants one, it stops writing prose and emits a small structured block: a name and some JSON arguments. Your program reads that block, calls your own ordinary function, and pastes the return value back into the conversation. Then you ask the model again.

That's it. That's the loop. Four stations, going round:

1 · messagesthe conversation so far, plus your list of tools

request

2 · modelreplies with text or a tool_use block

append

tool_use

4 · resulta tool_result carrying the same id

tool_result

3 · your coderuns the real function, fetch, disk, database

The agent loop. Station 3 is the only place anything actually happens, and it's your code, not the model's. Keep going round until the model answers with plain text instead of a tool call. That condition, and nothing more, is what “the agent finished” means.

Notice what's not in that picture. No memory. No planning module. No framework. The conversation array is the state, and the loop is the program. A model with no tools is a writer. A model with tools and this loop is an agent.

What the model actually sends back

You describe a tool once, in JSON Schema: a name, a sentence of prose, and the shape of its arguments. The model reads that description the way it reads everything else. Then it writes arguments that fit the shape:

you write, once
{
  name: "get_weather",
  description:
    "Current weather for one city.",
  input_schema: {
    type: "object",
    properties: {      city: { type: "string" }    },    required: ["city"]  }
}

the model writes, per call
{"city": "Paris"}
The right-hand side is generated text, the same machinery that writes sentences, aimed at a schema. The two lit lines are all it's actually held to: the name it must use, and the fact that it can't leave it out. Everything else on the left, the description above all, is a prompt rather than documentation. You'll spend more time editing that one sentence than editing the loop.

Two consequences. First, arguments arrive as JSON, so parse them, never match on the raw string. Second, the description is a prompt, so a tool the model keeps misusing is usually a tool you described badly. Writing descriptions the model reads the way you meant is the follow-on to this lesson.

You don't need a framework for any of this. A framework will run the loop for you later in the lesson, and it's the same four stations underneath.

The code, JavaScript

Complete and runnable. npm i @anthropic-ai/sdk, set ANTHROPIC_API_KEY, and run it with Node. Three parts: the function, the description, the loop.

import Anthropic from "@anthropic-ai/sdk";

const client = new Anthropic();   // reads ANTHROPIC_API_KEY

// 1. the tool. A plain function. Nothing about it is special.
function getWeather({ city }) {
  const readings = { Paris: "18°C, light rain", Tokyo: "27°C, clear" };
  return readings[city] ?? `no reading for ${city}`;
}

// 2. the description. This is what the model reads, it is a prompt.
const tools = [{
  name: "get_weather",
  description: "Current weather for one city. Use this for any question " +
               "about temperature, rain, or conditions right now.",
  input_schema: {
    type: "object",
    properties: {
      city: { type: "string", description: "City name, e.g. Paris" },
    },
    required: ["city"],
  },
}];

// 3. the loop. The entire agent, right here.
const messages = [
  { role: "user", content: "Do I need an umbrella in Paris?" },
];

while (true) {
  const reply = await client.messages.create({
    model: "claude-opus-5",
    max_tokens: 4096,
    tools,
    messages,
  });

  // Append the reply *unchanged* and in full. Do not rebuild it from the
  // text, the blocks you drop are the ones the next turn needs.
  messages.push({ role: "assistant", content: reply.content });

  // No tool wanted? The model is answering. We are done.
  if (reply.stop_reason !== "tool_use") {
    console.log(reply.content.filter(b => b.type === "text")
                             .map(b => b.text).join(""));
    break;
  }

  // It asked. Run every request, and answer every request.
  const results = reply.content
    .filter(b => b.type === "tool_use")
    .map(b => ({
      type: "tool_result",
      tool_use_id: b.id,                        // the id is the whole contract
      content: String(getWeather(b.input)),
    }));

  messages.push({ role: "user", content: results });
}

The odd-looking part: tool results go back with role: "user". They aren't from a user. It still reads strangely on the hundredth time. It's simply where the protocol puts them: the model's turn, then the turn that answers it.

The code, Python

The same program, line for line. pip install anthropic.

import anthropic

client = anthropic.Anthropic()          # reads ANTHROPIC_API_KEY

# 1. the tool 
def get_weather(city):
    readings = {"Paris": "18°C, light rain", "Tokyo": "27°C, clear"}
    return readings.get(city, f"no reading for {city}")

# 2. the description
tools = [{
    "name": "get_weather",
    "description": "Current weather for one city. Use this for any question "
                   "about temperature, rain, or conditions right now.",
    "input_schema": {
        "type": "object",
        "properties": {
            "city": {"type": "string", "description": "City name, e.g. Paris"},
        },
        "required": ["city"],
    },
}]

# 3. the loop
messages = [
    {"role": "user", "content": "Do I need an umbrella in Paris?"},
]

while True:
    reply = client.messages.create(
        model="claude-opus-5",
        max_tokens=4096,
        tools=tools,
        messages=messages,
    )

    # Append the reply unchanged and in full, blocks and all.
    messages.append({"role": "assistant", "content": reply.content})

    if reply.stop_reason != "tool_use":
        print("".join(b.text for b in reply.content if b.type == "text"))
        break

    results = [
        {
            "type": "tool_result",
            "tool_use_id": block.id,            # the id is the whole contract
            "content": str(get_weather(**block.input)),
        }
        for block in reply.content if block.type == "tool_use"
    ]

    messages.append({"role": "user", "content": results})

get_weather(**block.input) is the Python spelling of the JavaScript { city } destructure: the schema's property names become the function's parameter names, so the arguments unpack straight into the call.

What the conversation looks like afterwards

One question, one tool call, and the array has four entries. Watch it fill:

  1. user, Do I need an umbrella in Paris?
  2. assistant, tool_use id: toolu_01A · get_weather · {"city": "Paris"}
  3. user, tool_result tool_use_id: toolu_01A · 18°C, light rain
  4. assistant, Yes: light rain in Paris at 18°C. Take one.
Turn 3 is the one people get wrong. Its tool_use_id must be the exact id from turn 2, and every tool_use needs a matching result before you send the array again. Mismatch the ids or skip one and the request is rejected. Of the errors you hit this week, that's the kind to hope for. It fails loudly.

You send that whole array again on every turn. The model has no memory between requests. The array is the memory. A ten-step agent's last request carries all ten steps. That's why lesson 5 is about context, and why the bill grows the way it does.

One agent run, five requests. The hatched part of each bar is the conversation you already sent; the solid part is the only new thing in it. Nothing here is a leak or a mistake. This is what stateless means. It's also why tool output that comes back fat (a whole file, a whole page of JSON) costs you on every remaining turn, not just the one that asked for it.

What that costs, with the arithmetic spelled out

Do the addition on that chart. Five requests: 0.3k + 0.9k + 2.0k + 2.9k + 4.2k = 10.3k input tokens billed for a conversation that ends at 4.2k. You pay 2.5× the size of the thing you built. At Claude Opus 5's $5 per million input tokens that run costs about $0.05, five cents, and nobody notices.

Now make one tool fat. A read_file that returns an 8k-token source file on turn 2 of a ten-turn agent gets resent on the eight requests that follow: 8 × 8k = +64k input tokens, $0.32, from one tool result. Run that agent a thousand times a day and the single read_file you never trimmed costs $320 a day. Ouch.

We hit this in the harness too. Every step it takes resends the whole array, so the setting that earns its place in the panel is max steps, 40 by default. It's the ceiling on how many more times one fat tool result gets billed.

one fat tool_result · a ten-turn run

turn 2 · read_filereturns the whole file, 8k tokens, and the array keeps it

  1. 3
  2. 4
  3. 5
  4. 6
  5. 7
  6. 8
  7. 9
  8. 10
+64k input tokens · $0.32 · per run
Eight blocks, one mistake. Each is a later request carrying that same 8k again, because you send the whole array every time and nothing ever takes the file back out of it. The bar underneath steps up once per request, and it steps rather than slides for a reason: the bill grows in whole tool results, so the cheapest edit you'll ever make is the one that decides how much comes back at station 3. Return the twelve lines you needed and seven of these eight blocks go away.
Trim the tool result, not the prompt. The prompt is sent once. The tool result is sent on every turn that comes after it.

Two fixes, and you can use both. Return less: line ranges instead of whole files, counts instead of dumps, the twenty matching rows instead of the table. And cache the prefix. cache_control makes a repeated prefix bill at roughly 0.1× the input rate, against a 1.25× premium the one time it's written. Two requests over the same prefix and you're already ahead. That's lesson 5's whole subject. The number is here so you know why it gets a lesson.

Four rules that will save you a weekend

  • Answer every call, including the failures. If your function throws, don't drop the result. Send it back as a tool_result with is_error: true and the message in the content. The model reads errors and retries sensibly. A missing result is a protocol violation. A returned error is just information.
  • Return all results in one message. The model may ask for three tools at once. Run them, then send all three tool_result blocks in a single user message. Splitting them across messages quietly teaches the model to stop asking in parallel, and your agent gets slower for no visible reason.
  • Append the reply whole. Push reply.content, not a string you rebuilt from it. On current models the reply carries blocks besides text, and dropping them costs you quality on the next turn with no error to point at.
  • Cap the loop. while (true) is fine in a lesson. In anything real, count the turns and stop at twenty. A model that has misread a tool description will happily call it forty times, and you'd rather find that out from a counter than from an invoice.

station 3 throws ENOENT: notes/paris.md, the disk said no, and your function raised

  • you drop the result the array now holds a tool_use with nothing answering it, so the next request is the one that fails
  • you send it back a tool_result with is_error: true and the message as its content, ordinary information, in the ordinary place
A failing tool is not an error in the loop; a missing result is. Catch around your own function, put whatever it threw into the content, and the model does the sensible thing: fixes the path, tries the other tool, or tells the user it can't. Let the exception escape instead and you break the protocol one turn later, which is where the confusing stack trace comes from.
one assistant turn · three tool_use blocks
one user message
  • tool_result · toolu_01A · 412 bytes
  • tool_result · toolu_01B · 9 entries
  • tool_result · toolu_01C · 2 matches
Three calls out, one message back. The shape to avoid is three user messages carrying one result each: it's legal, it works, and it quietly trains the model out of asking in parallel, so your agent takes three round trips where it used to take one, with nothing in the logs to blame.
turns taken · one per trip round the ring

turn 1, the question turn 20, you stop

The counter is four lines of code and it's the difference between a bug and an invoice. A model that has misread one tool description doesn't crash. It calls the tool again, reasonably, forever, and every one of those turns carries the whole array with it. The hatched ticks past the wall are the calls you never paid for. Pick a number, break at it, and log the fact that you did: hitting the cap is always a description problem worth reading about later.

The trade-off: your loop vs. the SDK's

Now that you have written the loop by hand, stop writing it. Both SDKs run it for you, client.beta.messages.tool_runner() in Python with the @beta_tool decorator, client.beta.messages.toolRunner() in TypeScript with betaZodTool. Ten lines instead of twenty, and the four rules above come for free.

Here is what you give up. The runner owns the control flow, so anything you want between turns, an approval gate before a write, a log line per call, a retry with a rewritten argument, a turn counter you own, goes through its per-turn hooks instead of a line you drop into your own while. That's a fair trade in a real project. It's a bad trade while you're learning. Write the loop yourself exactly once, which is now, so the runner never surprises you.

Straight about what we run: the harness in the next tab doesn't use a tool runner. It has the loop from this lesson written out by hand in js/agent.js, because it runs in a browser tab against fetch with no dependencies at all. Read it after the exercise. It's the same four stations, plus a step counter and a settings panel.

The same four stations show up everywhere, under different names:

  • Python, anthropic, tool_runner + @beta_tool.
  • TypeScript / JavaScript, @anthropic-ai/sdk, toolRunner + betaZodTool. Vercel's AI SDK wraps the same loop as generateText({ tools }).
  • Go, Java, Ruby, C#, PHP, official Anthropic SDKs, each with a tool-runner entry point.
  • Local models, Ollama and llama.cpp speak OpenAI-style tools. Different JSON, same ring.
  • MCP, not another loop. It is a way to get tools, so someone else's server fills your tools array.

Pick whichever one your stack already uses. If you can point at the four stations in it, you can debug it.

The exercise

Do this in the harness. It's the same loop, already running, with twenty-one tools wired to a virtual filesystem. Open KEYS, paste a key or point it at Ollama, then:

make notes/paris.md with three lines about the weather, then read it back

Watch the transcript rather than the answer. You are looking for the shape from the diagram: a tool_use for the write, a tool_result confirming it, a second tool_use for the read. Two trips round the ring, then prose.

  1. user, make notes/paris.md with three lines about the weather, then read it back
  2. assistant, tool_use write_file · {"path": "notes/paris.md", …}
  3. user, tool_result wrote 3 lines, 87 bytes
  4. assistant, tool_use read_file · {"path": "notes/paris.md"}
  5. user, tool_result the three lines, back verbatim
  6. assistant, Written and read back. Here is what it says…
The shape to check your run against. Six entries, two trips, and note that the model does not ask permission between them. It writes, sees that the write worked, and only then reads. If your transcript shows the read before the result of the write has landed, you have found the bug this whole lesson is about: the loop must wait at station 3.

Then break it on purpose, which is the half people skip:

  • Turn a tool off in TOOLS and ask for it anyway. The model doesn't error. It improvises, and watching it improvise badly teaches you more about tool descriptions than any amount of reading.
  • Ask for something no tool covers. See whether it says so or invents a plausible answer.
  • In your own script, misspell tool_use_id. Read the error text. You'll meet it again.

Safe to break

Nothing in that list can hurt anything. The harness writes to a virtual filesystem in localStorage in your own tab, so write_file never touches your disk. /wipe deletes every file and the conversation with it, and /undo puts them back until you close the tab. Your key stays in the browser. The only real cost is tokens, and a run like the one above is a fraction of a cent. Break it on purpose while it's cheap.

Next week

A follow-on to this lesson gets to the part that decides whether your agent is any good: writing tool descriptions the model reads the way you meant. Then lesson 3, loops and goals takes this loop apart properly and asks the question this one dodged. Here we stopped when the model stopped calling tools. What if it never stops?

Twenty lines. Four stations. That's the whole distance between a model that writes about the weather and a program that goes and checks. Every tool you add from here is a thing your software can now do on someone's behalf, and choosing which ones deserve to be in that array is the actual craft.

Where this sits in the whole course, and what comes after: the syllabus.

If someone forwarded you this, the lessons are free and weekly and the archive keeps the ones you missed:

open the harness

联系我们 contact @ memedata.com