Към съдържанието
wannacode

Инструменти: моделът пита, програмата прави

Накратко: Инструментите (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"}.

При заявка програмата:

  1. Добавя отговора на модела в историята, с роля assistant.
  2. Изпълнява инструмента срещу своите данни.
  3. Връща резултата като ново user съобщение с блок tool_result. В него tool_use_id е същият id, а при грешка is_error е истина.
  4. Пита модела пак, с цялата история.

Това е цикълът с инструменти (tool use). Моделът не пуска нищо и не вижда базата. Той само казва какво му трябва, а програмата решава дали и как да го направи. Затова инструментите на асистента само четат: статус на поръчка, срок за връщане.

Входът на инструмента идва от модела, тоест отвън. Програмата го проверява, както проверява формуляр.

Виж го

Инструментът и блоковете

Инструментът, който моделът може да поиска. Описанието е за модела: по него той решава кога да го ползва.

const TOOLS = [
{
name: "get_order_status",
description: "Статус и дата на доставка на поръчка по номер.",
input_schema: {
type: "object",
properties: { order_id: { type: "string" } },
required: ["order_id"],
},
},
];
Блоковете са обекти с полетата на API-то: 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} викания → към човек`);
}
Стъпка по стъпка, с думи
  1. Въпросът е първото съобщение. Отива при модела заедно със списъка с инструменти (~36 токена).
  2. Моделът не знае статуса. Отговаря със stop_reason tool_use: „пусни get_order_status с order_id 1042“.
  3. Програмата пази отговора на модела в историята: резултатът трябва да има заявка, на която отговаря.
  4. Програмата изпълнява инструмента срещу своите данни. Моделът не пипа магазина: той само поиска.
  5. Резултатът се връща като user съобщение: tool_result със същия id, call_1.
  6. Викане 2 праща цялата история отново: ~46 токена вместо ~36.
  7. Сега моделът има данните и отговаря с 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 !== "") {

Пробвай

Предвиди изхода

Клиентът само благодари. Какво печата answer?

const client = new FakeClient();
answer(client, "Благодаря, това е всичко.");
Отговор

Идеята

Когато инструментът не успее

Инструментите се провалят: номерът го няма, полето липсва, базата не отговаря. Програмата не хвърля изключение и не крие грешката. Тя я връща на модела като 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 няма отговор. На кой ред е грешката?

Избери реда с грешката.

Идеята

Пусни при теб

Тази програма не е в проверката на урока: трябва ти профил при доставчик на модели, ключ и пари за всяко извикване. Ключът е в 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“?

Тест

Провери се

1. Моделът отговори със stop_reason tool_use и блок get_order_status с order_id 1042. Кой търси поръчката в базата?
2. В един отговор има два блока tool_use. Как моделът разбира кой резултат за коя заявка е?
3. Клиентът пита за две неща и моделът иска двата инструмента в един отговор. Колко викания към модела прави answer()?
4. Защо цикълът има граница MAX_CALLS?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·