Инструменты и агенты
run_tool_loop берёт на себя весь цикл: вызывает модель, выполняет запрошенные
инструменты через dispatch и повторяет до финального ответа без tool-вызовов.
def get_weather(city: str) -> str:
return f"В {city} сейчас 20°C"
tools = [{
"type": "function",
"function": {
"name": "get_weather",
"description": "Текущая погода в городе",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
},
},
}]
broker = llmbroker.Broker()
reply = llmbroker.run_tool_loop(
broker,
[{"role": "user", "content": "Какая погода в Москве?"}],
tools=tools,
dispatch={"get_weather": get_weather},
)
print(reply.text, "— ответила", reply.llm_name)
Цикл возвращает результат последнего раунда — ровно такой же, как у chat:
текст, ответившую модель и usage этого раунда. Каждый предыдущий раунд был
самостоятельным вызовом со своей строкой в журнале, поэтому счётчики здесь — за
последний раунд, а не за весь цикл; сумму читайте по строкам журнала.
Асинхронная версия — await llmbroker.arun_tool_loop(...) поверх
AsyncBroker.
Цикл ограничен max_steps (по умолчанию 8). Если модель и после последнего шага
просит инструменты, поднимается llmbroker.ToolLoopLimitError, а не возвращается
пустой ответ — увеличьте max_steps или перехватите исключение, чтобы забрать
то, что уже получилось.
Что цикл передаёт брокеру
Всё лишнее, что вы передали, уходит в каждый chat цикла: operation=,
trace_id=, wait=. Ставьте их так же, как на обычном вызове, — иначе раунды
цикла попадут в общий безымянный bucket и оценка качества
ничему не научится:
reply = llmbroker.run_tool_loop(
broker,
messages,
tools=tools,
dispatch={"get_weather": get_weather},
operation="weather-agent",
trace_id=request_id,
)
Один trace_id на весь цикл соберёт все раунды задачи в одну трассу журнала —
это удобно и так и задумано. А вот оценивать по нему нужно с оглядкой: цикл это
несколько самостоятельных вызовов, и оценка по трассе найдёт
из них один — последний ответивший раунд. Нужен другой — сохраните его call_id.
Исключение вашего инструмента наружу не выходит
dispatch вызывается циклом, но его ошибки не поднимаются к вам: если функция
бросила, модель получает обратно текст Tool <имя> failed: <ошибка> и решает,
что делать дальше — обычно исправляет аргументы и просит ещё раз. Модель,
запросившая инструмент, которого в dispatch нет, получает Unknown tool <имя>.
Такой же строкой возвращается и то, что функция вернула: результат приводится к
строке, поэтому отдавайте из инструмента текст или JSON, а не объект.
Из этого следует и то, что цикл не заметит: инструмент, который «упал» тихо,
съест шаг цикла, а не остановит его. Если вам нужно, чтобы отказ инструмента ронял
запрос, ловите это внутри своей функции и возвращайте модели явный отказ — либо
ведите цикл сами: chat(messages, tools=...) возвращает результат с tool_calls,
и дальше всё в ваших руках.
Цикл берёт брокер, а не вызывающего
Первым аргументом идёт сам брокер. В многопользовательском сервисе, где вызовы
делает вызывающий со скоупом, передать в цикл
broker.for_scope(user) работает, но объявленному типу не соответствует —
проверка типов на это пожалуется. Пока цикл принимает брокер, скоупу здесь места
нет.