Инструменти: моделът пита, програмата прави
Накратко: Инструментите (tool use) позволяват на модела да поиска от програмата да изпълни функция, например статус на поръчка, и да отговори от резултата, а не наум.
Проблем
Моделът не знае твоите поръчки
Клиентът пита: „Кога ще пристигне поръчка 1042?“. Асистентът пита модела, без да му даде данните:
var client = new FakeClient();var question = "Кога ще пристигне поръчка 1042?";List<Message> messages = [new("user", [new TextBlock(question)])];var reply = client.Create(SystemPrompt, messages, []);Console.WriteLine($"модел: {TextOf(reply)}");var order = Orders["1042"];Console.WriteLine($"магазин: {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). Моделът не пуска нищо и не вижда базата. Той само казва какво му трябва, а програмата решава дали и как да го направи. Затова инструментите на асистента само четат: статус на поръчка, срок за връщане.
Входът на инструмента идва от модела, тоест отвън. Програмата го проверява, както проверява формуляр.Виж го
Инструментът и блоковете
Инструментът, който моделът може да поиска. Описанието е за модела: по него той решава кога да го ползва.
static class Shop{ public const string SystemPrompt = "Ти си асистент на поддръжката на онлайн магазин. За поръчките питай инструментите."; public const int MaxCalls = 4;
public static readonly Tool[] Tools = [ new("get_order_status", "Статус и дата на доставка на поръчка по номер.", ["order_id"]), ];
public static readonly Dictionary<string, Order> Orders = new() { ["1042"] = new("изпратена", "03.11", "17.11"), ["1187"] = new("доставена", "26.10", "09.11"), };}Block:abstract record Block;
record TextBlock(string Text) : Block;
record ToolUse(string Id, string Name, Dictionary<string, string> Input) : Block;
record ToolResult(string ToolUseId, string Content, bool IsError) : Block;
record Message(string Role, List<Block> Content);
record Reply(List<Block> Content, string StopReason, int InputTokens, int OutputTokens);
record Tool(string Name, string Description, string[] Required);
record Order(string Status, string Eta, string ReturnBy);Програмата изпълнява поисканото. Първо проверява дали има такъв инструмент и дали входът има задължителните полета, после търси поръчката:
static ToolResult RunTool(ToolUse use){ var tool = Tools.FirstOrDefault(t => t.Name == use.Name); if (tool == null) { return new ToolResult(use.Id, $"няма инструмент {use.Name}", true); } foreach (var field in tool.Required) { if (!use.Input.ContainsKey(field)) { return new ToolResult(use.Id, $"липсва {field}", true); } } var orderId = use.Input["order_id"]; if (!Orders.TryGetValue(orderId, out var order)) { return new ToolResult(use.Id, $"няма поръчка {orderId}", true); } var text = $"{orderId} — {order.Status}, доставка {order.Eta}"; return new ToolResult(use.Id, text, false);}Всяка грешка е резултат с is_error, не изключение. Моделът трябва да разбере какво се е случило и да реши какво да каже на клиента.
Виж го
Цикълът
static void Answer(FakeClient client, string question){ Console.WriteLine($"въпрос: {question}"); List<Message> messages = [new("user", [new TextBlock(question)])]; for (var call = 1; call <= MaxCalls; call++) { var reply = client.Create(SystemPrompt, messages, Tools); Console.WriteLine($" викане {call} " + $"(~{reply.InputTokens} токена): {reply.StopReason}"); if (reply.StopReason != "tool_use") { Console.WriteLine($" отговор: {TextOf(reply)}"); return; } messages.Add(new("assistant", reply.Content)); var results = new List<Block>(); foreach (var use in reply.Content.OfType<ToolUse>()) { var result = RunTool(use); var shown = (result.IsError ? "✗ " : "") + result.Content; Console.WriteLine( $" {use.Id} {Describe(use)} → {shown}"); results.Add(result); } messages.Add(new("user", results)); } Console.WriteLine($" спирам след {MaxCalls} викания → към човек");}static ToolResult RunTool(ToolUse use){ var tool = Tools.FirstOrDefault(t => t.Name == use.Name); if (tool == null) { return new ToolResult(use.Id, $"няма инструмент {use.Name}", true); } foreach (var field in tool.Required) { if (!use.Input.ContainsKey(field)) { return new ToolResult(use.Id, $"липсва {field}", true); } } var orderId = use.Input["order_id"]; if (!Orders.TryGetValue(orderId, out var order)) { return new ToolResult(use.Id, $"няма поръчка {orderId}", true); } var text = $"{orderId} — {order.Status}, доставка {order.Eta}"; return new ToolResult(use.Id, text, false);}Стъпка по стъпка, с думи
- Въпросът е първото съобщение. Отива при модела заедно със списъка с инструменти (~36 токена).
- Моделът не знае статуса. Отговаря със stop_reason tool_use: „пусни get_order_status с order_id 1042“.
- Програмата пази отговора на модела в историята: резултатът трябва да има заявка, на която отговаря.
- Програмата изпълнява инструмента срещу своите данни. Моделът не пипа магазина: той само поиска.
- Резултатът се връща като user съобщение: tool_result със същия id, call_1.
- Викане 2 праща цялата история отново: ~46 токена вместо ~36.
- Сега моделът има данните и отговаря с end_turn. Две викания към модела за един въпрос.
Целият цикъл. Две помощни функции казват какво е поискал моделът и кой е текстът на отговора:
static string Describe(ToolUse use) => $"{use.Name}({use.Input.GetValueOrDefault("order_id", "")})";
static string TextOf(Reply reply) => reply.Content.OfType<TextBlock>().FirstOrDefault()?.Text ?? "";static void Answer(FakeClient client, string question){ Console.WriteLine($"въпрос: {question}"); List<Message> messages = [new("user", [new TextBlock(question)])]; for (var call = 1; call <= MaxCalls; call++) { var reply = client.Create(SystemPrompt, messages, Tools); Console.WriteLine($" викане {call} " + $"(~{reply.InputTokens} токена): {reply.StopReason}"); if (reply.StopReason != "tool_use") { Console.WriteLine($" отговор: {TextOf(reply)}"); return; } messages.Add(new("assistant", reply.Content)); var results = new List<Block>(); foreach (var use in reply.Content.OfType<ToolUse>()) { var result = RunTool(use); var shown = (result.IsError ? "✗ " : "") + result.Content; Console.WriteLine( $" {use.Id} {Describe(use)} → {shown}"); results.Add(result); } messages.Add(new("user", results)); } Console.WriteLine($" спирам след {MaxCalls} викания → към човек");}var 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?
var client = new FakeClient();Answer(client, "Благодаря, това е всичко.");Едно викане. Моделът има инструмент, но не е длъжен да го ползва: за „благодаря“ няма какво да проверява и отговаря направо с end_turn. Цикълът не изпълнява нищо и свършва на първата проверка.
Идеята
Когато инструментът не успее
Инструментите се провалят: номерът го няма, полето липсва, базата не отговаря. Програмата не хвърля изключение и не крие грешката. Тя я връща на модела като tool_result с is_error, а моделът решава какво да каже:
var 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. Проверката хваща липсващото поле, а моделът пита клиента за номера.
Някои модели не спират. Този гадае номера и иска нов и нов:
var 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-ът на заявката отгоре.// Точната заглавка за ключа е в документацията на доставчика.using var http = new HttpClient();var key = Environment.GetEnvironmentVariable("LLM_API_KEY");http.DefaultRequestHeaders.Add("Authorization", "Bearer " + key);var content = new StringContent(requestJson, Encoding.UTF8, "application/json");var response = await http.PostAsync("https://<доставчик>/messages", content);Console.WriteLine(await response.Content.ReadAsStringAsync());Истинските 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{ const string MadeUp = "Поръчка 1042 ще пристигне до петък."; const string AskId = "Кой е номерът на поръчката ви?"; const string NotFound = "Не намирам такава поръчка. Проверете номера, моля."; const string Thanks = "Моля! Пишете, ако имате друг въпрос."; int lastId = 0;
public Reply Create(string system, List<Message> messages, Tool[] tools) { var question = ((TextBlock)messages[0].Content[0]).Text; var last = messages[^1].Content; var content = new List<Block>(); if (tools.Length == 0) { content.Add(new TextBlock(MadeUp)); } else if (question.Contains("около")) { // Гадае следващ номер, докато някой не го спре. var guess = 1050 + messages.Count / 2; Ask(content, tools, "get_order_status", guess.ToString()); } else if (last[0] is ToolResult) { content.Add(new TextBlock(AfterResults(last))); } else { if (question.Contains("1042")) { Ask(content, tools, "get_order_status", "1042"); } if (question.Contains("9999")) { Ask(content, tools, "get_order_status", "9999"); } if (question.Contains("1187")) { Ask(content, tools, "get_return_window", "1187"); } if (question.Contains("пратката")) { Ask(content, tools, "get_order_status", null); } if (content.Count == 0) { content.Add(new TextBlock(Thanks)); } } var input = Tokens(system) + tools.Sum(t => Tokens(t.Name + " " + t.Description)) + messages.Sum(m => m.Content.Sum(b => Tokens(Words(b)))); var output = content.Sum(b => Tokens(Words(b))); var stop = content[0] is ToolUse ? "tool_use" : "end_turn"; return new Reply(content, stop, input, output); }
void Ask(List<Block> content, Tool[] tools, string name, string orderId) { if (!tools.Any(t => t.Name == name)) { return; } var input = new Dictionary<string, string>(); if (orderId != null) { input["order_id"] = orderId; } lastId++; content.Add(new ToolUse($"call_{lastId}", name, input)); }
// Истинският модел преразказва; този само слепва резултатите. static string AfterResults(List<Block> results) { var parts = new List<string>(); foreach (ToolResult r in results) { if (r.IsError) { return r.Content.Contains("order_id") ? AskId : NotFound; } parts.Add(r.Content); } return "Проверих: " + string.Join("; ", parts) + "."; }
static string Words(Block block) => block switch { TextBlock t => t.Text, ToolUse u => u.Name + " " + string.Join(" ", u.Input.Values), ToolResult r => r.Content, _ => "", };
// Грубо: думите × 1,3, закръглено нагоре. static int Tokens(string text) { var words = text.Split((char[])null, StringSplitOptions.RemoveEmptyEntries).Length; return (words * 13 + 9) / 10; }}abstract record Block е общият родител, а is ToolResult и OfType<ToolUse>() избират вида. switch в Words има _, защото компилаторът на C# не знае, че наследниците са само три.Упражнение
Срокът за връщане
Реши задачата в редактора си.
Клиентът пита: „Кога пристига 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