Skip to content

Инструменты и агенты

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) работает, но объявленному типу не соответствует — проверка типов на это пожалуется. Пока цикл принимает брокер, скоупу здесь места нет.