Тесты инструментов, узлов и маршрутизаторов, подмена модели заглушкой, имитация инструментов, наборы примеров и эксперименты в LangSmith, программные оценщики и модель-судья.
Агент недетерминирован: на один и тот же запрос он может ответить по-разному. Это не отменяет тестирование, но меняет то, что именно проверяют.
Привычный тест сравнивает результат с эталоном. С агентом это не работает: формулировка ответа меняется от запуска к запуску, порядок вызова инструментов тоже. Тест, сверяющий строку целиком, станет мигать и через неделю его отключат.
Выход в разделении. Детерминированную часть системы (инструменты, узлы, маршрутизаторы) тестируют обычными тестами со строгими проверками. Недетерминированную часть (решения модели) оценивают статистически на наборе примеров.
Практическое правило: если поведение зависит от модели, тест должен проверять свойство, а не точный текст. «Вызван инструмент поиска», «в ответе есть сумма и валюта», «агент не обратился к платёжному инструменту без подтверждения».
Инструмент — это функция с аннотациями. Модели в тесте нет, поэтому проверяют её как любой другой код: краевые случаи, ошибки, форматы.
def test_search_returns_summary():
result = search_database.invoke({"query": "оплата", "limit": 5})
assert "оплата" in result
assert len(result) < 2000 # результат не должен раздувать контекстОбратите внимание на вторую проверку. Ограничение размера ответа — это тоже требование, и его удобно закрепить тестом: разросшийся ответ инструмента бьёт по стоимости всех последующих шагов.
Если инструмент декорирован @tool, вызывать его в тесте нужно через invoke со словарём аргументов: декоратор превращает функцию в объект инструмента.
Отдельно стоит проверять схему аргументов. Она попадает в промпт, и её поломка ломает агента, хотя код формально работает:
def test_tool_schema_is_stable():
schema = search_database.args_schema.model_json_schema()
assert set(schema["properties"]) == {"query", "limit"}
assert schema["properties"]["query"]["description"]Узел графа принимает состояние и возвращает обновление. Никакого рантайма для этого не нужно, если внутри нет обращения к модели:
def test_classify_detects_billing():
result = classify({"text": "Не пришёл счёт за апрель", "category": "", "reply": ""})
assert result == {"category": "billing"}Маршрутизатор проверяется так же и особенно этого заслуживает: ошибка в нём отправляет весь поток не туда, а на глаз такое незаметно.
def test_route_sends_to_tools_when_tool_calls_present():
state = {"messages": [AIMessage(content="", tool_calls=[{"name": "search", "args": {}, "id": "1"}])]}
assert should_continue(state) == "tools"Чтобы проверить сборку графа целиком, модель заменяют предсказуемой заглушкой. Тогда тест проверяет то, что относится к вашему коду: маршрутизацию, обновления состояния, обработку ошибок инструментов.
Один из способов подмены — это мидлварь, перехватывающая вызов модели:
from langchain.agents.middleware import wrap_model_call
from langchain.messages import AIMessage
def stub_model(responses):
calls = iter(responses)
@wrap_model_call
def _stub(request, handler):
return next(calls)
return _stub
def test_agent_calls_tool_then_answers():
agent = create_agent(
model="openai:gpt-4o-mini",
tools=[get_weather],
middleware=[stub_model([
AIMessage(content="", tool_calls=[{"name": "get_weather", "args": {"city": "Казань"}, "id": "1"}]),
AIMessage(content="В Казани солнечно."),
])],
)
result = agent.invoke({"messages": [{"role": "user", "content": "Погода в Казани?"}]})
assert "солнечно" in result["messages"][-1].textПервый ответ заглушки просит вызвать инструмент, второй завершает цикл. Тест выполняется мгновенно, не тратит токены и падает ровно тогда, когда ломается логика.
Обратная задача возникает, когда модель нужна настоящая, а инструменты трогать нельзя: они пишут в базу, отправляют письма, стоят денег. Здесь помогает LLMToolEmulator, который подменяет выполнение инструментов сгенерированными ответами:
from langchain.agents.middleware import LLMToolEmulator
agent = create_agent(
model="openai:gpt-4o-mini",
tools=[charge_card, send_email],
middleware=[LLMToolEmulator(tools=["charge_card", "send_email"])],
)Это удобно на ранней стадии, когда интеграции ещё не готовы, и для проверки поведения агента в сценариях, которые трудно воспроизвести по-настоящему (отказ платежа, пустой результат поиска).
Тесты отвечают на вопрос «работает ли код». Вопрос «стало ли лучше после правки промпта» они не закрывают. Для этого нужен набор примеров и прогон по нему.
Набор — это список входов с ожидаемым поведением. Двадцати-тридцати реальных запросов достаточно, чтобы ловить регрессии; собирают их из настоящего трафика, а не придумывают.
from langsmith import Client
client = Client()
dataset = client.create_dataset(
dataset_name="support-agent-v1",
description="Реальные обращения в поддержку с ожидаемым исходом",
)
client.create_examples(
dataset_id=dataset.id,
examples=[
{
"inputs": {"question": "Где мой счёт за апрель?"},
"outputs": {"category": "billing", "must_call": "find_invoice"},
},
],
)Оценка запускается функцией, которая прогоняет агента на каждом примере, и набором оценщиков. Оценщик — это функция, получающая вход, полученный результат и эталон:
def called_expected_tool(inputs: dict, outputs: dict, reference_outputs: dict) -> bool:
tools_used = {tc["name"] for msg in outputs["messages"] for tc in getattr(msg, "tool_calls", [])}
return reference_outputs["must_call"] in tools_used
def target(inputs: dict) -> dict:
return agent.invoke({"messages": [{"role": "user", "content": inputs["question"]}]})
client.evaluate(
target,
data="support-agent-v1",
evaluators=[called_expected_tool],
experiment_prefix="prompt-v3",
)Результаты сохраняются как эксперимент, и два прогона можно сравнить между собой. Именно это превращает «мне кажется, стало лучше» в число.
Программные оценщики проверяют факты: вызван ли нужный инструмент, есть ли в ответе обязательные поля, уложился ли запуск в лимит шагов, не превышена ли стоимость. Они дешёвые, быстрые и не врут, поэтому начинать стоит с них.
Оценщики на основе модели нужны там, где проверяется качество текста: соответствует ли ответ вопросу, вежлив ли тон, нет ли выдуманных фактов. Модель-судья получает вопрос, ответ и критерии и выставляет оценку.
У судьи есть особенности, о которых стоит знать заранее. Он сам недетерминирован, поэтому его оценкам верят в агрегате, а не поштучно. Критерии нужно формулировать узко: «содержит ли ответ конкретную сумму» работает надёжнее, чем «хороший ли это ответ». И его тоже полезно проверить: прогнать по примерам с известной оценкой человека и посмотреть, совпадает ли.
Часть проверок имеет смысл выполнять на реальном трафике: доля ответов без вызова инструментов, доля запусков, упёршихся в лимит, оценка судьи на выборке запросов. Это дополняет офлайн-набор, потому что реальные пользователи всегда приносят сценарии, которых в наборе нет.
Обратная связь от пользователей (палец вверх или вниз) тоже фиксируется как оценка и потом становится источником новых примеров для набора: те запросы, где агент ошибся, самые ценные.
Юнит-тесты инструментов и узлов гоняются на каждый коммит: они быстрые и детерминированные.
Тесты графа с подменённой моделью тоже подходят для каждого коммита по той же причине.
Прогон набора примеров запускают перед выкладкой и после значимых правок промпта или набора инструментов. Он стоит денег и времени, поэтому на каждый коммит его обычно не ставят.
Набор растёт из инцидентов. Каждый разбор жалобы заканчивается новым примером, и через несколько месяцев набор начинает ловить регрессии лучше любого ручного тестирования.
Сравнение ответа со строкой-эталоном. Тест начнёт мигать при первом же обновлении модели.
Тестирование только счастливого пути. Отказ инструмента, пустой результат и некорректный ввод дают больше информации о живучести агента.
Набор из придуманных примеров. Реальные запросы содержат опечатки, сленг и смешанные намерения, которых в придуманных не бывает.
Доверие единичной оценке судьи. Смотреть нужно на распределение по набору, а не на вердикт по одному примеру.
Прогон набора без фиксации версии промпта. Сравнивать эксперименты можно, только если понятно, что именно менялось между ними.
Далее: Продакшен: развёртывание и лучшие практики