Инструменти: моделът пита, програмата прави
Накратко: Инструментите (tool use) позволяват на модела да поиска от програмата да изпълни функция, например статус на поръчка, и да отговори от резултата, а не наум.
Проблем
Моделът не знае твоите поръчки
Клиентът пита: „Кога ще пристигне поръчка 1042?“. Асистентът пита модела, без да му даде данните:
const client = new FakeClient();const question = "Кога ще пристигне поръчка 1042?";const messages = [{ role: "user", content: [{ type: "text", text: question }] }];const reply = client.create(SYSTEM, messages, []);console.log(`модел: ${textOf(reply)}`);const order = ORDERS["1042"];console.log(`магазин: ${order.status}, доставка ${order.eta}`);модел: Поръчка 1042 ще пристигне до петък.магазин: изпратена, доставка 03.11Моделът казва „до петък“, а в магазина пише 03.11. Той не е видял базата ти. Има само текста, който си му пратил, и съчинява правдоподобно.
В урока за промпта данните на поръчката влизаха в шаблона. Това работи, когато знаеш предварително кои данни трябват. Но клиентът може да пита за друга поръчка, за връщане или за две неща наведнъж. Не можеш да сложиш целия магазин в промпта: контекстът е ограничен, а всяка дума струва пари.
Идеята
Моделът пита, програмата прави
Даваш на модела списък с инструменти. Всеки има име, описание и схема на входа: същата схема като в урока за структурирания изход. Моделът вече може да отговори по два начина:
- С текст.
stop_reasonеend_turnи разговорът свършва. - Със заявка.
stop_reasonеtool_use, а в отговора има блокtool_use:id, име на инструмента и вход, например{"order_id": "1042"}.
При заявка програмата:
- Добавя отговора на модела в историята, с роля
assistant. - Изпълнява инструмента срещу своите данни.
- Връща резултата като ново
userсъобщение с блокtool_result. В негоtool_use_idе същиятid, а при грешкаis_errorе истина. - Пита модела пак, с цялата история.
Това е цикълът с инструменти (tool use). Моделът не пуска нищо и не вижда базата. Той само казва какво му трябва, а програмата решава дали и как да го направи. Затова инструментите на асистента само четат: статус на поръчка, срок за връщане.
Входът на инструмента идва от модела, тоест отвън. Програмата го проверява, както проверява формуляр.Виж го
Инструментът и блоковете
Инструментът, който моделът може да поиска. Описанието е за модела: по него той решава кога да го ползва.
const TOOLS = [ { name: "get_order_status", description: "Статус и дата на доставка на поръчка по номер.", input_schema: { type: "object", properties: { order_id: { type: "string" } }, required: ["order_id"], }, },];type казва вида, а tool_use има id, name и input. В Python и JavaScript инструментът по-горе е точно във формата, която API-то приема.Програмата изпълнява поисканото. Първо проверява дали има такъв инструмент и дали входът има задължителните полета, после търси поръчката:
function runTool(use) { const tool = TOOLS.find((t) => t.name === use.name); if (tool === undefined) return error(use, `няма инструмент ${use.name}`); for (const field of tool.input_schema.required) { if (!Object.hasOwn(use.input, field)) return error(use, `липсва ${field}`); } const orderId = use.input.order_id; const order = ORDERS[orderId]; if (order === undefined) return error(use, `няма поръчка ${orderId}`); const text = `${orderId} — ${order.status}, доставка ${order.eta}`; return { type: "tool_result", tool_use_id: use.id, content: text };}
function error(use, text) { return { type: "tool_result", tool_use_id: use.id, content: text, is_error: true };}Всяка грешка е резултат с is_error, не изключение. Моделът трябва да разбере какво се е случило и да реши какво да каже на клиента.
Виж го
Цикълът
function answer(client, question) { console.log(`въпрос: ${question}`); const messages = [ { role: "user", content: [{ type: "text", text: question }] }, ]; for (let call = 1; call <= MAX_CALLS; call++) { const reply = client.create(SYSTEM, messages, TOOLS); console.log(` викане ${call} (~${reply.inputTokens} токена):`, reply.stopReason); if (reply.stopReason !== "tool_use") { console.log(` отговор: ${textOf(reply)}`); return; } messages.push({ role: "assistant", content: reply.content }); const results = []; for (const block of reply.content) { if (block.type === "tool_use") { const result = runTool(block); const shown = (result.is_error ? "✗ " : "") + result.content; console.log(` ${block.id} ${describe(block)} → ${shown}`); results.push(result); } } messages.push({ role: "user", content: results }); } console.log(` спирам след ${MAX_CALLS} викания → към човек`);}function runTool(use) { const tool = TOOLS.find((t) => t.name === use.name); if (tool === undefined) return error(use, `няма инструмент ${use.name}`); for (const field of tool.input_schema.required) { if (!Object.hasOwn(use.input, field)) return error(use, `липсва ${field}`); } const orderId = use.input.order_id; const order = ORDERS[orderId]; if (order === undefined) return error(use, `няма поръчка ${orderId}`); const text = `${orderId} — ${order.status}, доставка ${order.eta}`; return { type: "tool_result", tool_use_id: use.id, content: text };}
function error(use, text) { return { type: "tool_result", tool_use_id: use.id, content: text, is_error: true };}Стъпка по стъпка, с думи
- Въпросът е първото съобщение. Отива при модела заедно със списъка с инструменти (~36 токена).
- Моделът не знае статуса. Отговаря със stop_reason tool_use: „пусни get_order_status с order_id 1042“.
- Програмата пази отговора на модела в историята: резултатът трябва да има заявка, на която отговаря.
- Програмата изпълнява инструмента срещу своите данни. Моделът не пипа магазина: той само поиска.
- Резултатът се връща като user съобщение: tool_result със същия id, call_1.
- Викане 2 праща цялата история отново: ~46 токена вместо ~36.
- Сега моделът има данните и отговаря с end_turn. Две викания към модела за един въпрос.
Целият цикъл. Две помощни функции казват какво е поискал моделът и кой е текстът на отговора:
function describe(use) { return `${use.name}(${use.input.order_id ?? ""})`;}
function textOf(reply) { return reply.content.find((b) => b.type === "text")?.text ?? "";}function answer(client, question) { console.log(`въпрос: ${question}`); const messages = [ { role: "user", content: [{ type: "text", text: question }] }, ]; for (let call = 1; call <= MAX_CALLS; call++) { const reply = client.create(SYSTEM, messages, TOOLS); console.log(` викане ${call} (~${reply.inputTokens} токена):`, reply.stopReason); if (reply.stopReason !== "tool_use") { console.log(` отговор: ${textOf(reply)}`); return; } messages.push({ role: "assistant", content: reply.content }); const results = []; for (const block of reply.content) { if (block.type === "tool_use") { const result = runTool(block); const shown = (result.is_error ? "✗ " : "") + result.content; console.log(` ${block.id} ${describe(block)} → ${shown}`); results.push(result); } } messages.push({ role: "user", content: results }); } console.log(` спирам след ${MAX_CALLS} викания → към човек`);}const client = new FakeClient();answer(client, "Кога ще пристигне поръчка 1042?");въпрос: Кога ще пристигне поръчка 1042? викане 1 (~36 токена): tool_use call_1 get_order_status(1042) → 1042 — изпратена, доставка 03.11 викане 2 (~46 токена): end_turn отговор: Проверих: 1042 — изпратена, доставка 03.11.Едно питане на клиента прави две викания към модела. Второто праща цялата история и е по-голямо: ~46 токена вместо ~36.
Моделът тук е фалшив клиент като в предишните уроци: по думите на въпроса иска инструментите, които има, а след резултатите сглобява отговор от тях. Правилото му е в „По-точно“.
Пробвай
Попълни
Кога цикълът спира и печата отговора? Какво липсва в проверката?
if (reply.stopReason !== "") {tool_use. Докато моделът иска инструменти, програмата ги изпълнява и пита пак. Всяко друго stop_reason (end_turn, но и max_tokens или refusal) значи, че моделът е приключил.
Пробвай
Предвиди изхода
Клиентът само благодари. Какво печата answer?
const client = new FakeClient();answer(client, "Благодаря, това е всичко.");Едно викане. Моделът има инструмент, но не е длъжен да го ползва: за „благодаря“ няма какво да проверява и отговаря направо с end_turn. Цикълът не изпълнява нищо и свършва на първата проверка.
Идеята
Когато инструментът не успее
Инструментите се провалят: номерът го няма, полето липсва, базата не отговаря. Програмата не хвърля изключение и не крие грешката. Тя я връща на модела като tool_result с is_error, а моделът решава какво да каже:
const client = new FakeClient();answer(client, "Къде е поръчка 9999?");answer(client, "Къде ми е пратката?");въпрос: Къде е поръчка 9999? викане 1 (~35 токена): tool_use call_1 get_order_status(9999) → ✗ няма поръчка 9999 викане 2 (~42 токена): end_turn отговор: Не намирам такава поръчка. Проверете номера, моля.въпрос: Къде ми е пратката? викане 1 (~35 токена): tool_use call_2 get_order_status() → ✗ липсва order_id викане 2 (~40 токена): end_turn отговор: Кой е номерът на поръчката ви?- Поръчка 9999 я няма. Моделът казва това на клиента, вместо да измисли статус.
- Въпросът е без номер. Моделът все пак поиска инструмента, но без
order_id. Проверката хваща липсващото поле, а моделът пита клиента за номера.
Някои модели не спират. Този гадае номера и иска нов и нов:
const client = new FakeClient();answer(client, "Не помня номера, около 1050 беше.");въпрос: Не помня номера, около 1050 беше. викане 1 (~37 токена): tool_use call_1 get_order_status(1050) → ✗ няма поръчка 1050 викане 2 (~44 токена): tool_use call_2 get_order_status(1051) → ✗ няма поръчка 1051 викане 3 (~51 токена): tool_use call_3 get_order_status(1052) → ✗ няма поръчка 1052 викане 4 (~58 токена): tool_use call_4 get_order_status(1053) → ✗ няма поръчка 1053 спирам след 4 викания → към човекЗатова цикълът има граница: най-много 4 викания, после разговорът отива при човек. Без границата програмата върти, докато свършат парите.
Пробвай
Намери грешката
Колега пише по-кратък runTool. Програмата праща резултата, но API-то отказва второто викане: за tool_use няма отговор. На кой ред е грешката?
Избери реда с грешката.
Резултатът е вързан за името на инструмента, а не за id-то на заявката. API-то търси tool_result с tool_use_id, равен на id-то на всеки tool_use. Името не е уникално: два блока може да искат get_order_status за две поръчки. На реда с грешката сложи use.id.
Идеята
Пусни при теб
Тази програма не е в проверката на урока: трябва ти профил при доставчик на модели, ключ и пари за всяко извикване. Ключът е в LLM_API_KEY, никога в кода. Доставчиците имат официални SDK-та за Java, C#, Python и JavaScript със същата форма (съобщения, роли, токени, инструменти); точните имена на класовете и методите са в документацията на доставчика. Ето какво пътува по мрежата за въпроса за 1042. Първата заявка носи инструментите, а името на модела е заместител:
{ "model": "<името на модела>", "max_tokens": 1024, "system": "Ти си асистент на поддръжката на онлайн магазин. За поръчките питай инструментите.", "tools": [ { "name": "get_order_status", "description": "Статус и дата на доставка на поръчка по номер.", "input_schema": { "type": "object", "properties": {"order_id": {"type": "string"}}, "required": ["order_id"] } } ], "messages": [ { "role": "user", "content": "Кога ще пристигне поръчка 1042?" } ]}Отговорът е заявка за инструмент:
{ "content": [ { "type": "tool_use", "id": "call_1", "name": "get_order_status", "input": {"order_id": "1042"} } ], "stop_reason": "tool_use", "usage": {"input_tokens": 36, "output_tokens": 3}}Втората заявка носи цялата история: въпроса, tool_use на модела и tool_result на програмата със същия id:
{ "model": "<името на модела>", "max_tokens": 1024, "system": "Ти си асистент на поддръжката на онлайн магазин. За поръчките питай инструментите.", "tools": [ { "name": "get_order_status", "description": "Статус и дата на доставка на поръчка по номер.", "input_schema": { "type": "object", "properties": {"order_id": {"type": "string"}}, "required": ["order_id"] } } ], "messages": [ { "role": "user", "content": "Кога ще пристигне поръчка 1042?" }, { "role": "assistant", "content": [ { "type": "tool_use", "id": "call_1", "name": "get_order_status", "input": {"order_id": "1042"} } ] }, { "role": "user", "content": [ { "type": "tool_result", "tool_use_id": "call_1", "content": "1042 — изпратена, доставка 03.11" } ] } ]}{ "content": [ { "type": "text", "text": "Проверих: 1042 — изпратена, доставка 03.11." } ], "stop_reason": "end_turn", "usage": {"input_tokens": 46, "output_tokens": 8}}При грешка резултатът има is_error:
{ "type": "tool_result", "tool_use_id": "call_1", "content": "няма поръчка 9999", "is_error": true}Всяка заявка се праща със стандартния HTTP клиент на езика, а цикълът около нея е същият като в answer. Адресът е заместител:
// requestJson е JSON-ът на заявката отгоре.// Точната заглавка за ключа е в документацията на доставчика.const response = await fetch("https://<доставчик>/messages", { method: "POST", headers: { Authorization: `Bearer ${process.env.LLM_API_KEY}`, "Content-Type": "application/json", }, body: requestJson,});console.log(await response.text());Истинските id-та са дълги случайни низове. Фалшивият клиент ги брои: call_1, call_2.
Идеята
Кога вреди
- Инструмент, който прави, а не чете. „Отмени поръчката“ или „върни парите“ не се дават на модела без човек, който потвърждава. Моделът може да сбърка поръчката, а грешката тук струва пари.
- Твърде много инструменти. Всеки инструмент е описание, което се праща при всяко викане. Двайсет инструмента са стотици токени и модел, който по-често избира грешния.
- Неясни описания. Моделът избира по описанието. „Връща данни“ не казва нищо. „Статус и дата на доставка на поръчка по номер“ казва кога да го ползва.
- Доверие на входа.
order_idидва от модела. Програмата проверява, че поръчката съществува и че е на този клиент, преди да покаже нещо. Ако инструментът чете чужд текст (писмо, страница), в текста може да има инструкции към модела. Тази атака (prompt injection) има свой урок в модула за агентите. - Цикъл без граница. Всяко викане праща цялата история и струва повече от предишното. Колко точно, е в урока за цената и бързината. А дали асистентът отговаря вярно на сто въпроса, а не на един, се мери с тестов набор.
По-точно, за любопитните
Защо отговорът на модела влиза в историята. API-то няма памет. При второто викане моделът вижда само това, което му пратиш. tool_result без tool_use преди него е отговор без въпрос и API-то го отказва.
Два инструмента в едно викане. Моделът може да поиска няколко инструмента наведнъж. Програмата ги изпълнява всичките и връща всички резултати в едно user съобщение, всеки със своя tool_use_id. Така виканията към модела са по-малко.
Принудителен избор. API-то има и поле tool_choice. Стойността по подразбиране auto оставя модела да реши дали да ползва инструмент. Новите модели отказват да бъдат принуждавани към инструмент, затова този урок не го прави.
Фалшивият клиент отвътре. Ако няма инструменти, измисля срок. Ако въпросът е „около 1050“, гадае номер след номер. Ако последното съобщение е с резултати, сглобява отговор от тях, а при грешка пита за номера или казва, че не намира поръчката. Иначе иска инструмента по номера във въпроса: 1042 и 9999 за статус, 1187 за връщане. Токените са думите × 1,3, а описанията на инструментите се броят при всяко викане.
// Стои на мястото на истинския модел: записани отговори,// без мрежа и всеки път едни и същи.class FakeClient { static MADE_UP = "Поръчка 1042 ще пристигне до петък."; static ASK_ID = "Кой е номерът на поръчката ви?"; static NOT_FOUND = "Не намирам такава поръчка. Проверете номера, моля."; static THANKS = "Моля! Пишете, ако имате друг въпрос."; lastId = 0;
create(system, messages, tools) { const question = messages[0].content[0].text; const last = messages[messages.length - 1].content; const content = []; if (tools.length === 0) { content.push({ type: "text", text: FakeClient.MADE_UP }); } else if (question.includes("около")) { // Гадае следващ номер, докато някой не го спре. const guess = 1050 + Math.floor(messages.length / 2); this.ask(content, tools, "get_order_status", String(guess)); } else if (last[0].type === "tool_result") { content.push({ type: "text", text: FakeClient.afterResults(last) }); } else { if (question.includes("1042")) this.ask(content, tools, "get_order_status", "1042"); if (question.includes("9999")) this.ask(content, tools, "get_order_status", "9999"); if (question.includes("1187")) this.ask(content, tools, "get_return_window", "1187"); if (question.includes("пратката")) this.ask(content, tools, "get_order_status", null); if (content.length === 0) content.push({ type: "text", text: FakeClient.THANKS }); } let inputTokens = tokens(system); for (const t of tools) inputTokens += tokens(t.name + " " + t.description); for (const m of messages) { for (const b of m.content) inputTokens += tokens(words(b)); } let outputTokens = 0; for (const b of content) outputTokens += tokens(words(b)); const stopReason = content[0].type === "tool_use" ? "tool_use" : "end_turn"; return { content, stopReason, inputTokens, outputTokens }; }
ask(content, tools, name, orderId) { if (!tools.some((t) => t.name === name)) return; const input = orderId === null ? {} : { order_id: orderId }; this.lastId++; content.push({ type: "tool_use", id: `call_${this.lastId}`, name, input }); }
// Истинският модел преразказва; този само слепва резултатите. static afterResults(results) { const parts = []; for (const r of results) { if (r.is_error) { return r.content.includes("order_id") ? FakeClient.ASK_ID : FakeClient.NOT_FOUND; } parts.push(r.content); } return "Проверих: " + parts.join("; ") + "."; }}
function words(block) { if (block.type === "text") return block.text; if (block.type === "tool_use") { return block.name + " " + Object.values(block.input).join(" "); } return block.content;}
// Грубо: думите × 1,3, закръглено нагоре.function tokens(text) { const t = text.trim(); return Math.floor(((t ? t.split(/\s+/).length : 0) * 13 + 9) / 10);}Упражнение
Срокът за връщане
Реши задачата в редактора си.
Клиентът пита: „Кога пристига 1042 и до кога мога да върна 1187?“. Сега моделът има само get_order_status и отговаря наполовина:
въпрос: Кога пристига 1042 и до кога мога да върна 1187? викане 1 (~42 токена): tool_use call_1 get_order_status(1042) → 1042 — изпратена, доставка 03.11 викане 2 (~52 токена): end_turn отговор: Проверих: 1042 — изпратена, доставка 03.11.Добави втори инструмент get_return_window с описание „До коя дата може да се върне поръчка по номер.“ и вход order_id. В runTool за него върни 1187 — връщане до … с полето за връщане на поръчката. Моделът ще поиска двата инструмента в едно викане:
въпрос: Кога пристига 1042 и до кога мога да върна 1187? викане 1 (~57 токена): tool_use call_1 get_order_status(1042) → 1042 — изпратена, доставка 03.11 call_2 get_return_window(1187) → 1187 — връщане до 09.11 викане 2 (~77 токена): end_turn отговор: Проверих: 1042 — изпратена, доставка 03.11; 1187 — връщане до 09.11.Още задачи
- Чужда поръчка. Добави към поръчката клиента, на когото е, и към
answerклиента, който пита.runToolвръща грешка за поръчка на друг клиент. Какво казва моделът тогава? - Бюджет на токени. Спри цикъла и когато сборът на входните токени мине 100. На кое викане спира въпросът „около 1050“?
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Границите на модела · Извикване на модел през API · Промптът е код · Структуриран изход: JSON по схема
- Продължава в: Цикълът на агента: план, действие, наблюдение · Prompt injection: писмото, което дава нареждания · Предпазители: проверки преди и след модела · Асистентът на поддръжката
- Задачи по темата: Приложения с LLM