最新资讯
stackoverflow.blog 欢迎来到芯片时代:半导体制造、AI 数据中心与芯片设计的未来 www.youtube.com AI Agent 总犯同样的错:CentralAgent AI 将记忆重构为经验系统 pub.towardsai.net Netflix 科学家建议重采样 LLM 评判:5 次仅将误差棒缩小 13% pub.towardsai.net Jev 作为 LLM 前的静默层:构建提示感知的模型路由器 reddit.com OpenAI 称 Astra 仅满足 10% 安全任务标准 dev.to FreeToken:在自有 GPU 上运行前沿 MoE 模型及其内存需求 dev.to 当 AI Agent 说“完成”时,究竟完成了什么? dev.to 部署 Underdog Bark 3.0 Pro:逐字转写、自定义后端与分块设置 dev.to 为艰难对话构建 AI:五个 Agent、一条安全正则与一位盲评监督者 techcrunch.com Palo Alto Networks 洽谈以 5 亿美元收购 AI Agent 安全初创公司 Clutch theinformation.com 黄仁勋被要求就涉华间谍指控向国会作简报 www.infoq.com Edera 推出 Object Capability Bounded Authority,试图将凭证与 AI Agent 工作负载隔离
多元动态
登录 注册
行业动态 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

来源报道 · 1 篇 · 以下链接均前往原始媒体

← 返回精选动态 原文

更多内容