行业动态
buttercup.sh ·
@Hacker News
用 40 行原生 JavaScript 讲清 AI tool calling 的工作机制
文章用约 40 行原生 JavaScript 端到端演示 tool calling 循环:模型本身不执行任何操作,只根据 JSON Schema 描述的工具列表生成包含名称和 JSON 参数的 tool_use 块;开发者的代码负责调用真实函数,并把携带相同 id 的 tool_result 追加回对话,循环直到模型返回纯文本。作者强调对话数组即状态、循环即程序,无需记忆或规划模块,并指出参数应按 JSON 解析、工具描述本质是提示词,描述不当会导致模型误用工具。
AI推荐理由 以约 40 行原生 JavaScript 完整演示 tool calling 循环,明确区分模型只生成结构化 tool_use 块、真正执行发生在开发者代码中,并给出 tool_use_id 不匹配的失败示例;对理解 Agent 循环有教学价值,但属入门讲解,无新模型或新工具信息,且正文未提供实测效果数据。
72
正文
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:
user, Do I need an umbrella in Paris?
assistant, tool_use
id: toolu_01A · get_weather ·
{"city": "Paris"}
user, tool_result
tool_use_id: toolu_01A · 18°C, light rain
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.
request 1
0.3k
request 2
0.9k
request 3
2.0k
request 4
2.9k
request 5
4.2k
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
3
4
5
6
7
8
9
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
read_file
list_dir
grep
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.
user, make notes/paris.md with three lines about the
weather, then read it back
assistant, tool_use
write_file ·
{"path": "notes/paris.md", …}
user, tool_result wrote 3 lines,
87 bytes
assistant, tool_use
read_file · {"path": "notes/paris.md"}
user, tool_result the three lines,
back verbatim
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