LangChain

Сообщения и контент-блоки | Курс LangChain урок 3

Сообщения и контент-блоки | Курс LangChain урок 3
Михаил Омельченко
Автор
Михаил Омельченко
Опубликовано 25.09.2026
0,0
Views 5

Цель урока: собирать диалог из сообщений с ролями, читать ответ модели по частям, а не одной строкой. И понимать, что лежит внутри сообщения с вызовом инструмента, с картинкой и с рассуждением модели.

Необходимые знания:

1) урок 0: окружение собрано, ключ работает, переменные MODEL_NAME и MODEL_BASE_URL заполнены

2) урок 1: токены, расход в usage_metadata, режим рассуждения и параметр reasoning_effort

3) урок 2: вызов модели через init_chat_model, методы invoke, batch, stream

4) Python на уровне джуниора: словари, списки, классы, обработка исключений, файлы

Ключевые концепции:

1) четыре типа сообщений и три способа их записать

2) content против text: почему содержимое бывает списком

3) контент-блоки как общий формат поверх форматов разных провайдеров

4) output_version="v1" и стандартные блоки внутри content

5) токены рассуждения: блок reasoning и поле output_token_details

6) сообщение с вызовом инструмента: tool_calls и ToolMessage

7) мультимодальный ввод: картинка, звук, документ внутри сообщения

8) сериализация диалога через dumpd и load, и зачем вызову список разрешённых классов


Зачем сообщению роль

В уроке 1 вы выяснили неприятную вещь: модель ничего не помнит. Каждый запрос уходит на сервер сам по себе, и всё, что модель знает о разговоре, вы кладёте в этот запрос своими руками. Отсюда вопрос: в каком виде класть?

Первое, что приходит в голову, это склеить разговор в одну строку. Так делать можно, но работать это будет плохо. Модель не отличит вашу инструкцию от текста пользователя, а свой прошлый ответ от нового вопроса, строка однородна.

Сообщение это объект из трёх частей: роль, содержимое и необязательные метаданные. Роль отвечает на вопрос "кто это сказал", содержимое несёт сам текст или другие данные, метаданные хранят идентификатор, счётчики токенов, ответ провайдера.

Начну с ролей. Примеры урока вызывают модель через build_model из модуля course_model.py, того же, что в уроках 1 и 2. Положите его рядом с примерами урока 3.

import os

from dotenv import load_dotenv
from langchain.chat_models import init_chat_model

load_dotenv()


def build_model(**kwargs):
    """Собирает модель курса.

    Все именованные аргументы уходят в init_chat_model как есть: temperature,
    output_version, max_tokens и прочее из раздела Parameters.
    """
    model_name = os.environ["MODEL_NAME"]
    base_url = os.getenv("MODEL_BASE_URL")

    if base_url:
        # Путь для любого адреса, совместимого с OpenAI Chat Completions API.
        return init_chat_model(
            model=model_name,
            model_provider="openai",
            base_url=base_url,
            api_key=os.environ["OPENAI_API_KEY"],
            **kwargs,
        )

    # Путь напрямую к провайдеру.
    return init_chat_model(model_name, **kwargs)

Первый пример собирает один и тот же диалог тремя способами.

Пример 01_roles.py

from langchain.messages import AIMessage, HumanMessage, SystemMessage

from course_model import build_model

model = build_model(temperature=0)

# 1. Диалог объектами сообщений. Роль задаётся классом, а не полем.
dialogue = [
    SystemMessage("Вы отвечаете одним предложением, без вступлений."),
    HumanMessage("Что такое очередь задач?"),
    AIMessage("Очередь задач, это список работ, которые исполнители разбирают по одной."),
    HumanMessage("А чем она отличается от стека?"),
]

for message in dialogue:
    print(f"{type(message).__name__:14} {message.content!r}")

print()
print("ОТВЕТ НА ОБЪЕКТАХ:", model.invoke(dialogue).text)
print()

# 2. Тот же диалог словарями. Роль задаётся полем role.
as_dicts = [
    {"role": "system", "content": "Вы отвечаете одним предложением, без вступлений."},
    {"role": "user", "content": "Что такое очередь задач?"},
    {"role": "assistant", "content": "Очередь задач, это список работ, которые исполнители разбирают по одной."},
    {"role": "user", "content": "А чем она отличается от стека?"},
]

print("ОТВЕТ НА СЛОВАРЯХ:", model.invoke(as_dicts).text)
print()

# 3. Строка это сокращение для списка из одного HumanMessage.
print("ОТВЕТ НА СТРОКУ:", model.invoke("Что такое очередь задач? Одно предложение.").text)
print()

# 4. Необязательные поля сообщения: имя автора и идентификатор.
named = HumanMessage(content="Здравствуйте!", name="alice", id="msg_123")

print("NAME:", named.name)
print("ID:  ", named.id)
print("ТИП ОТВЕТА МОДЕЛИ:", type(model.invoke([named])).__name__)

# Вывод:
# SystemMessage  'Вы отвечаете одним предложением, без вступлений.'
# HumanMessage   'Что такое очередь задач?'
# AIMessage      'Очередь задач, это список работ, которые исполнители разбирают по одной.'
# HumanMessage   'А чем она отличается от стека?'
#
# ОТВЕТ НА ОБЪЕКТАХ: Очередь задач работает по принципу FIFO (первым пришёл — первым ушёл), а стек — по принципу LIFO (последним пришёл — первым ушёл).
#
# ОТВЕТ НА СЛОВАРЯХ: Очередь задач работает по принципу FIFO (первым пришёл — первым вышел), а стек — по принципу LIFO (последним пришёл — первым вышел).
#
# ОТВЕТ НА СТРОКУ: Очередь задач — это структура данных, которая хранит список отложенных для выполнения операций (задач) и обрабатывает их по принципу «первым пришёл — первым обслужен» (FIFO).
#
# NAME: alice
# ID:   msg_123
# ТИП ОТВЕТА МОДЕЛИ: AIMessage

Объекты сообщений импортируются из langchain.messages, роль задаётся классом. Словари повторяют формат чата OpenAI, роль в них лежит полем role. Голая строка, это сокращение для списка из одного HumanMessage.

Что выбирать. Словари короче и привычны тем, кто напрямую работал с API провайдера. Объекты дают подсказки редактора и не дают опечататься в имени роли. Курс дальше пользуется и тем, и другим: где важна краткость, там словари, где важно показать устройство, там объекты.

Четыре типа сообщений

Тип Роль в словаре Кто создаёт Зачем нужен
SystemMessage system вы задаёт поведение модели: роль, тон, правила ответа
HumanMessage user вы ввод пользователя, текст и любые другие данные
AIMessage assistant модель ответ модели: текст, вызовы инструментов, метаданные
ToolMessage tool ваш код результат одного выполнения инструмента

Системное сообщение, это первый набор инструкций, который настраивает модель до разговора. Задавайте в нём тон, роль модели и правила ответов. Урок 5 возвращается к нему и показывает, что происходит с системным промптом внутри агента.

Сообщение пользователя не ограничено текстом: картинки, звук, документы, всё это едет в HumanMessage, подробности ниже, в разделе про мультимодальный ввод.

Сообщение модели вы обычно не создаёте руками, его возвращает вызов. Но в историю следующего запроса кладёте его вы, и вот тут закрывается вопрос из урока 2: почему ответ ассистента в списке пишете вы сами. Потому что на стороне провайдера этого ответа больше нет, взаимодействие не хранит состояния, и весь разговор целиком отправляется заново с каждым вызовом.

Отсюда же вторая возможность. Провайдеры по-разному взвешивают сообщения разных ролей, поэтому иногда полезно вставить в историю AIMessage так, как будто он пришёл от модели. Например, чтобы показать образец нужного ответа. В первом примере так и сделано: третьим элементом диалога стоит AIMessage, который модель никогда не писала.

У сообщения есть два необязательных поля, они стоят в четвёртом пункте примера. id отличает одно сообщение от другого, по нему сообщение находят в журнале запусков, например в LangSmith. Своему сообщению вы задаёте его сами, как msg_123 в примере. Ответу модели его ставит интеграция провайдера, а если не поставила, фреймворк подставляет свой в виде lc_run-....

name задаёт имя автора сообщения, например чтобы различать двух пользователей в одном разговоре. Что с ним сделает модель, зависит от провайдера: одни различают по нему пользователей, другие поле игнорируют. Это смотрите в справочнике интеграции своего провайдера.

При стриминге вместо одного AIMessage приходят куски AIMessageChunk, и оператор сложения собирает из них целое сообщение. Стриминг разобран в уроке 7.

Почему content бывает списком

В итогах урока 2 остался вопрос: почему содержимое сообщения бывает списком блоков, а не строкой.

Причина в том, что поле content намеренно слабо типизировано. Оно принимает и строку, и список нетипизированных объектов. Так в сообщение можно класть структуры конкретного провайдера как есть.

Отсюда три вида содержимого, которые вы можете увидеть в одном и том же поле.

1) строка. Обычный ответ текстовой модели

2) список блоков в формате провайдера. Так приезжают рассуждение, цитаты, картинки, серверные вызовы инструментов, причём у каждого провайдера поля свои

3) список стандартных блоков LangChain. Это общий формат, к которому фреймворк умеет приводить второй вариант

Посмотрите на настоящий ответ вашей модели.

Пример 02_content_and_text.py

from course_model import build_model

model = build_model(temperature=0)

response = model.invoke("Ответьте одним словом: столица Японии")

print("ТИП СООБЩЕНИЯ:  ", type(response).__name__)
print("ТИП content:    ", type(response.content).__name__)
print("content:        ", repr(response.content))
print("ТИП text:       ", type(response.text).__name__)
print("text:           ", repr(response.text))
print("content_blocks: ", response.content_blocks)
print("tool_calls:     ", response.tool_calls)
print("id:             ", response.id)
print()
print("КЛЮЧИ response_metadata:", sorted(response.response_metadata))
print("usage_metadata:         ", response.usage_metadata)

# Вывод:
# ТИП СООБЩЕНИЯ:   AIMessage
# ТИП content:     str
# content:         'Токио'
# ТИП text:        TextAccessor
# text:            'Токио'
# content_blocks:  [{'type': 'text', 'text': 'Токио'}]
# tool_calls:      []
# id:              lc_run--01a0b9aa-4845-71b0-a2bb-1afaf3aadd1e-0
#
# КЛЮЧИ response_metadata: ['finish_reason', 'id', 'logprobs', 'model_name', 'model_provider', 'system_fingerprint', 'token_usage']
# usage_metadata:          {'input_tokens': 16, 'output_tokens': 41, 'total_tokens': 57, 'input_token_details': {}, 'output_token_details': {}}

Тип content напечатан как str, а тип text как TextAccessor. Это наследник str: isinstance(response.text, str) возвращает True, сравнение, срезы и склейка со строкой работают как у обычной строки. Отдельный класс понадобился для совместимости, в версии 0 текст брали вызовом со скобками, и TextAccessor умеет обе формы, отвечая на старую предупреждением.

Основные поля AIMessage.

Поле Что внутри
text текст сообщения строкой
content сырое содержимое: строка либо список объектов
content_blocks то же содержимое, разобранное в стандартные блоки
tool_calls вызовы инструментов, пустой список, если их не было
id идентификатор сообщения от фреймворка или от провайдера
usage_metadata счётчики токенов, когда провайдер их прислал
response_metadata метаданные ответа провайдера

Печатайте text, а не content, если вам нужен текст. text это всегда строка, тот самый наследник str из вывода примера, независимо от того, что прислал провайдер. content может оказаться списком словарей, и тогда ваш print покажет структуру, а конкатенация со строкой упадёт с TypeError.

Контент-блоки: один формат поверх разных провайдеров

Пока вы работаете с одним провайдером, разнобой в content не мешает. Стоит поменять провайдера, и формат меняется: у Anthropic рассуждение приезжает блоком с типом thinking и полем thinking, у OpenAI блоком с типом reasoning, внутри которого лежит список summary. Один и тот же смысл, две разные структуры, и ваш код с разбором по if придётся писать под каждого.

Контент-блок это стандартное представление куска содержимого, одинаковое у всех провайдеров. Свойство content_blocks разбирает content в этот вид лениво, то есть в момент обращения, и ничего в самом сообщении не меняет.

По-английски это content blocks, или standard content blocks. Там, где их надо отличить от блоков в формате провайдера, они называются стандартными блоками.

Следующий пример работает без сети и без ключа. Сообщения в нём собраны руками ровно в том виде, в каком их присылают два разных провайдера.

Пример 03_content_blocks.py

from langchain.messages import AIMessage


def show(title, message):
    """Печатает три взгляда на одно и то же сообщение."""
    print(title)
    print("  ТИП content:    ", type(message.content).__name__)
    print("  content:        ", message.content)
    print("  content_blocks: ", message.content_blocks)
    print("  text:           ", repr(message.text))
    print()


# 1. Содержимое строкой. Так выглядит ответ обычной текстовой модели.
show("СТРОКА", AIMessage("Париж, столица Франции."))

# 2. Блоки в формате Anthropic: рассуждение зовётся thinking.
show(
    "БЛОКИ ПРОВАЙДЕРА ANTHROPIC",
    AIMessage(
        content=[
            {"type": "thinking", "thinking": "...", "signature": "WaUjzkyp..."},
            {"type": "text", "text": "..."},
        ],
        response_metadata={"model_provider": "anthropic"},
    ),
)

# 3. То же самое в формате OpenAI: рассуждение зовётся reasoning и лежит в summary.
show(
    "БЛОКИ ПРОВАЙДЕРА OPENAI",
    AIMessage(
        content=[
            {
                "type": "reasoning",
                "id": "rs_abc123",
                "summary": [
                    {"type": "summary_text", "text": "summary 1"},
                    {"type": "summary_text", "text": "summary 2"},
                ],
            },
            {"type": "text", "text": "...", "id": "msg_abc123"},
        ],
        response_metadata={"model_provider": "openai"},
    ),
)

# Вывод:
# СТРОКА
#   ТИП content:     str
#   content:         Париж, столица Франции.
#   content_blocks:  [{'type': 'text', 'text': 'Париж, столица Франции.'}]
#   text:            'Париж, столица Франции.'
#
# БЛОКИ ПРОВАЙДЕРА ANTHROPIC
#   ТИП content:     list
#   content:         [{'type': 'thinking', 'thinking': '...', 'signature': 'WaUjzkyp...'}, {'type': 'text', 'text': '...'}]
#   content_blocks:  [{'type': 'reasoning', 'reasoning': '...', 'extras': {'signature': 'WaUjzkyp...'}}, {'type': 'text', 'text': '...'}]
#   text:            '...'
#
# БЛОКИ ПРОВАЙДЕРА OPENAI
#   ТИП content:     list
#   content:         [{'type': 'reasoning', 'id': 'rs_abc123', 'summary': [{'type': 'summary_text', 'text': 'summary 1'}, {'type': 'summary_text', 'text': 'summary 2'}]}, {'type': 'text', 'text': '...', 'id': 'msg_abc123'}]
#   content_blocks:  [{'type': 'reasoning', 'id': 'rs_abc123', 'reasoning': 'summary 1'}, {'type': 'reasoning', 'id': 'rs_abc123', 'reasoning': 'summary 2'}, {'type': 'text', 'text': '...', 'id': 'msg_abc123'}]
#   text:            '...'

Обратите внимание на аргумент response_metadata={"model_provider": ...}. По нему фреймворк выбирает, чей формат разбирать. В настоящем ответе это поле заполняет интеграция провайдера, здесь оно подставлено руками, потому что сообщение собрано без сети.

В выводе видно главное: content у двух сообщений с блоками разный, а content_blocks у них одинаковой формы. Блок рассуждения в обоих случаях становится словарём с типом reasoning и полем reasoning, а всё, чему в стандарте места не нашлось, вроде подписи Anthropic, уезжает в extras. Заметьте ещё, что два элемента summary у OpenAI превратились в два отдельных блока рассуждения с одним и тем же id.

Стандартные блоки делятся на пять групп.

Группа Блоки Когда встретите
Основные text, reasoning текст ответа и рассуждение модели
Мультимодальные image, audio, video, file, text-plain картинки, звук, документы на входе и на выходе
Вызов инструментов tool_call, tool_call_chunk, invalid_tool_call модель просит вызвать вашу функцию
Серверные инструменты server_tool_call, server_tool_call_chunk, server_tool_result поиск в интернете и исполнение кода на стороне провайдера
Нестандартные non_standard экспериментальные и особые возможности провайдера

Блок invalid_tool_call приходит, когда модель выдала аргументы вызова, которые не разбираются как JSON. Сломанный вызов инструмента становится отдельным типом блока в ответе, исключения в вашем коде нет. Подробно он разобран в уроке 9.

Оговорка к стандарту. Контент-блоки не заменяют content, они добавлены рядом с ним. Старый код, читающий content, продолжает работать. Соответственно, и данные провайдера никуда не деваются: если вам нужна подпись блока Anthropic или сырой формат ответа, они лежат в content и в extras.

Стандартные блоки внутри content

Ленивое свойство удобно, пока вы в Python. Но сообщение часто уезжает дальше: в очередь, в базу, в браузер по вебсокету. Свойство туда не уедет, уедет то, что лежит в полях.

На этот случай есть переключатель. Параметр output_version="v1" при создании модели кладёт стандартные блоки прямо в content. То же делает переменная окружения LC_OUTPUT_VERSION=v1.

Пример 04_output_version_v1.py

from course_model import build_model

QUESTION = "Ответьте одним словом: столица Италии"

default_model = build_model(temperature=0)
v1_model = build_model(temperature=0, output_version="v1")

default_response = default_model.invoke(QUESTION)
v1_response = v1_model.invoke(QUESTION)

print("ПО УМОЛЧАНИЮ")
print("  ТИП content:    ", type(default_response.content).__name__)
print("  content:        ", repr(default_response.content))
print("  content_blocks: ", default_response.content_blocks)
print()
print('С output_version="v1"')
print("  ТИП content:    ", type(v1_response.content).__name__)
print("  content:        ", repr(v1_response.content))
print("  content_blocks: ", v1_response.content_blocks)
print()
print("ТЕКСТ У ОБОИХ ОДИНАКОВЫЙ:", default_response.text.strip() == v1_response.text.strip())

# Вывод:
# ПО УМОЛЧАНИЮ
#   ТИП content:     str
#   content:         'Рим'
#   content_blocks:  [{'type': 'text', 'text': 'Рим'}]
#
# С output_version="v1"
#   ТИП content:     list
#   content:         [{'type': 'text', 'text': 'Рим'}]
#   content_blocks:  [{'type': 'text', 'text': 'Рим'}]
#
# ТЕКСТ У ОБОИХ ОДИНАКОВЫЙ: True

Когда это включать. Если вы отдаёте сообщения наружу из Python, во фронтенд или в чужой сервис, включайте: получатель увидит один формат независимо от провайдера. Если сообщения остаются внутри вашего процесса, переключатель не нужен, читайте content_blocks.

Оговорка для тех, кто работает с OpenAI через Responses API. Там пакет интеграции по умолчанию кладёт в content элементы ответа провайдера. Вернуть прежнее поведение можно значением output_version="v0" или переменной LC_OUTPUT_VERSION=v0. Это изменение входит в список ломающих изменений версии 1.

Токены рассуждения: где они лежат

В уроке 1 вы смотрели на рассуждение со стороны денег. У DeepSeek, модели курса, режим включён по умолчанию, выходные токены расходуются на невидимые вам размышления, а параметром reasoning_effort этот расход можно убавить.

Рассуждение видно в двух местах, и путать их не надо.

1) content_blocks, блоки с типом reasoning. Это сам текст рассуждения, если провайдер его отдаёт

2) usage_metadata, поле output_token_details и внутри него ключ reasoning. Это счётчик, сколько выходных токенов ушло на рассуждение

Пример 05_reasoning.py

from course_model import build_model

QUESTION = (
    "У Ани в два раза больше книг, чем у Бори, а вместе у них 51 книга. "
    "Сколько книг у Ани?"
)

model = build_model(temperature=0)
response = model.invoke(QUESTION)

reasoning_blocks = [b for b in response.content_blocks if b["type"] == "reasoning"]
text_blocks = [b for b in response.content_blocks if b["type"] == "text"]

print("ТИПЫ БЛОКОВ:       ", [block["type"] for block in response.content_blocks])
print("БЛОКОВ РАССУЖДЕНИЯ:", len(reasoning_blocks))
print("БЛОКОВ ТЕКСТА:     ", len(text_blocks))
print()

if reasoning_blocks:
    joined = " ".join(block.get("reasoning", "") for block in reasoning_blocks)
    print("РАССУЖДЕНИЕ, ПЕРВЫЕ 300 ЗНАКОВ:")
    print(" ", joined[:300])
else:
    print("Блоков рассуждения в сообщении нет.")

print()
print("ОТВЕТ:", response.text.strip()[:200])
print()

usage = response.usage_metadata or {}
details = usage.get("output_token_details") or {}

print("ВХОД: ", usage.get("input_tokens"))
print("ВЫХОД:", usage.get("output_tokens"))
print("ИЗ НИХ НА РАССУЖДЕНИЕ:", details.get("reasoning", "провайдер не разделил"))

# Вывод:
# ТИПЫ БЛОКОВ:        ['text']
# БЛОКОВ РАССУЖДЕНИЯ: 0
# БЛОКОВ ТЕКСТА:      1
#
# Блоков рассуждения в сообщении нет.
#
# ОТВЕТ: У Бори \(x\) книг, у Ани \(2x\) книг. Вместе:
# \(x + 2x = 51\)
# \(3x = 51\)
# \(x = 17\)
#
# У Ани: \(2 \cdot 17 = 34\) книги.
#
# Ответ: 34.
#
# ВХОД:  34
# ВЫХОД: 208
# ИЗ НИХ НА РАССУЖДЕНИЕ: провайдер не разделил

Блоки рассуждения приходят, только если модель рассуждение отдаёт, а интеграция его разбирает. На пути курса нет второго. Шлюз присылает текст рассуждения DeepSeek в поле reasoning_content, но ChatOpenAI работает по спецификации OpenAI и нестандартные поля провайдеров не извлекает. Поэтому сработала вторая ветка. Для DeepSeek есть отдельная интеграция, ChatDeepSeek из пакета langchain-deepseek.

Счётчик пуст по другой причине. Разбивку выхода не присылает сам провайдер: в сыром ответе поле completion_tokens_details равно null.

Практический смысл этой пары полей. Когда агент из будущих уроков начнёт тратить больше ожидаемого, разбираться вы будете именно здесь. Сколько ушло на видимый ответ, сколько на невидимое рассуждение, и не пора ли понизить reasoning_effort. Само по себе рассуждение в истории тоже занимает место, и что с этим делать, разберу в уроке 12, когда дело дойдёт до обрезки и суммаризации.

Сообщение с вызовом инструмента

В уроке 2 агент напечатал AIMessage с пустым содержимым. Выглядело это ошибкой, и разобрать такое сообщение я обещал здесь.

Разберу на цикле, собранном руками, без агента. Так виднее, из чего агент состоит внутри.

Пример 06_tool_call_message.py

from langchain.tools import tool

from course_model import build_model


@tool
def get_weather(city: str) -> str:
    """Get the current weather in a given city."""
    return f"В городе {city} сейчас +17 и дождь."


model_with_tools = build_model(temperature=0).bind_tools([get_weather])

messages = [{"role": "user", "content": "Какая погода в Казани?"}]
ai_message = model_with_tools.invoke(messages)

print("ТИП СООБЩЕНИЯ:", type(ai_message).__name__)
print("content:      ", repr(ai_message.content))
print("text:         ", repr(ai_message.text))
print("tool_calls:   ", ai_message.tool_calls)
print()

if not ai_message.tool_calls:
    print("Модель не попросила инструмент, продолжать цикл не с чем.")
    print("Так бывает: вызов инструментов держат не все модели и не все шлюзы.")
    raise SystemExit(0)

messages.append(ai_message)

for tool_call in ai_message.tool_calls:
    print("ИМЯ:      ", tool_call["name"])
    print("АРГУМЕНТЫ:", tool_call["args"])
    print("ID:       ", tool_call["id"])

    tool_message = get_weather.invoke(tool_call)

    print("ЧТО ВЕРНУЛ ИНСТРУМЕНТ:", type(tool_message).__name__)
    print("  content:     ", repr(tool_message.content))
    print("  tool_call_id:", tool_message.tool_call_id)
    print("  name:        ", tool_message.name)
    print()

    messages.append(tool_message)

final = model_with_tools.invoke(messages)

print("СОСТАВ ИСТОРИИ:", [type(m).__name__ if not isinstance(m, dict) else m["role"] for m in messages])
print("ФИНАЛЬНЫЙ ОТВЕТ:", final.text)

# Вывод:
# ТИП СООБЩЕНИЯ: AIMessage
# content:       ''
# text:          ''
# tool_calls:    [{'name': 'get_weather', 'args': {'city': 'Казань'}, 'id': 'call_db224038ac9347a1908c2c9f', 'type': 'tool_call'}]
#
# ИМЯ:       get_weather
# АРГУМЕНТЫ: {'city': 'Казань'}
# ID:        call_db224038ac9347a1908c2c9f
# ЧТО ВЕРНУЛ ИНСТРУМЕНТ: ToolMessage
#   content:      'В городе Казань сейчас +17 и дождь.'
#   tool_call_id: call_db224038ac9347a1908c2c9f
#   name:         get_weather
#
# СОСТАВ ИСТОРИИ: ['user', 'AIMessage', 'ToolMessage']
# ФИНАЛЬНЫЙ ОТВЕТ: Сейчас в Казани **+17°C** и идёт **дождь**. ☔
#
# Советую захватить зонт, если планируете выходить на улицу!

Декоратор @tool и метод bind_tools здесь взяты в минимальном виде, целиком тема инструментов, это урок 9.

Вот что происходит по шагам, и вот откуда бралась строка без слов в уроке 2.

1) вы отдаёте модели вопрос и список инструментов. Модель не выполняет функцию, она этого не умеет. Она возвращает просьбу её выполнить

2) просьба приезжает в AIMessage, но не в содержимом, а в отдельном поле tool_calls. Содержимое при этом остаётся без слов, в выводе выше это пустая строка

3) вы исполняете инструмент сами и получаете ToolMessage с результатом

4) вы дописываете оба сообщения в историю и зовёте модель снова. Теперь у неё есть данные, и она пишет ответ словами

Каждый вызов внутри tool_calls, это словарь из трёх полей и служебного type со значением 'tool_call'.

Поле Что внутри
name имя инструмента, который модель просит вызвать
args аргументы вызова словарём, уже разобранные из JSON
id идентификатор именно этого вызова

Идентификатор нужен для четвёртого шага. Результат возвращается сообщением ToolMessage, у которого поле tool_call_id обязано совпасть с id вызова: по этой паре ответ связывается с просьбой. Модель курса чужой идентификатор пропускает, другой провайдер такую историю может отклонить, подробнее в ошибке 3. В истории агента у каждого вызова инструмента должен быть свой ToolMessage.

Обратите внимание на строку tool_message = get_weather.invoke(tool_call). Вы передаёте инструменту не аргументы, а весь словарь вызова целиком, и получаете назад готовый ToolMessage с проставленным tool_call_id.

Состав самого ToolMessage такой.

Поле Что внутри
content результат работы инструмента: строка, а если инструмент вернул контент-блоки, список блоков
tool_call_id идентификатор вызова, на который это ответ
name имя вызванного инструмента
artifact данные, которые модели не отправляются, но доступны вашему коду

В artifact кладут то, что нужно программе и не нужно модели: сырой ответ сервиса, идентификаторы документов, отладочную информацию. Пример: поиск по документам. В содержимое идёт текст найденного отрывка, в artifact идентификатор документа и номер страницы, чтобы приложение могло показать нужное место. Контекст модели при этом не засоряется.

Мультимодальный ввод

До сих пор вы отправляли модели текст. Но HumanMessage умеет нести и картинку, и звук, и документ, и делается это теми же контент-блоками.

Три способа передать данные, и выбор между ними зависит от того, где лежит файл.

Способ Поле блока Когда подходит
Ссылка url файл уже лежит в интернете и провайдер может его скачать
Данные base64 вместе с mime_type файл у вас на диске, выкладывать его в интернет не нужно
Идентификатор file_id файл заранее загружен в хранилище самого провайдера

Пять типов блоков для данных: image, audio, video, file для документов вроде PDF и text-plain для текстовых файлов. Поле mime_type для данных в base64 обязательно.

Не все модели принимают все типы файлов. Форматы и ограничения по размеру смотрите в справочнике своего провайдера. OpenAI, например, требует имя файла для документов PDF. Такие дополнительные ключи кладутся либо в сам блок, либо внутрь extras.

Примет ли картинку модель курса, заранее неизвестно. Картинки принимает или не принимает конкретная модель у конкретного провайдера, фреймворк на это не влияет. Поэтому пример состоит из двух частей. Первая работает без сети и показывает формат сообщения. Вторая отправляет это сообщение и печатает то, что вернул провайдер: ответ модели или отказ.

Пример 07_multimodal.py

from langchain.messages import HumanMessage

from course_model import build_model

# Квадрат 8x8 в формате PNG, закодированный base64: все 64 точки цвета
# RGB (220, 50, 47), красный.
RED_SQUARE_PNG = (
    "iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAIAAABLbSncAAAAEUlEQVR4nGO4Y6SPFTEM"
    "LQkAItlPQVLZ9OgAAAAASUVORK5CYII="
)


def preview(block):
    """Обрезает длинные строки внутри блока: base64 занял бы весь экран."""
    if not isinstance(block, dict):
        return repr(block)

    trimmed = {}
    for key, value in block.items():
        if isinstance(value, str) and len(value) > 24:
            trimmed[key] = value[:24] + "..."
        else:
            trimmed[key] = value
    return trimmed


message = HumanMessage(
    content_blocks=[
        {"type": "text", "text": "Какого цвета этот квадрат? Ответьте одним словом."},
        {"type": "image", "base64": RED_SQUARE_PNG, "mime_type": "image/png"},
    ]
)

print("ЧТО ЛЕЖИТ В content:")
if isinstance(message.content, str):
    print(" ", repr(message.content))
else:
    for block in message.content:
        print(" ", preview(block))

print()
print("ЧТО ОТДАЁТ content_blocks:")
for block in message.content_blocks:
    print(" ", preview(block))

print()
print("ЗАПРОС К МОДЕЛИ КУРСА")

model = build_model(temperature=0)

try:
    response = model.invoke([message])
    print("  ОТВЕТ:", response.text.strip())
    print("  РАСХОД:", response.usage_metadata)
except Exception as error:  # noqa: BLE001
    print(f"  отказ: {type(error).__name__}")
    print(f"  текст: {error}")

# Вывод:
# ЧТО ЛЕЖИТ В content:
#   {'type': 'text', 'text': 'Какого цвета этот квадра...'}
#   {'type': 'image', 'base64': 'iVBORw0KGgoAAAANSUhEUgAA...', 'mime_type': 'image/png'}
#
# ЧТО ОТДАЁТ content_blocks:
#   {'type': 'text', 'text': 'Какого цвета этот квадра...'}
#   {'type': 'image', 'base64': 'iVBORw0KGgoAAAANSUhEUgAA...', 'mime_type': 'image/png'}
#
# ЗАПРОС К МОДЕЛИ КУРСА
#   ОТВЕТ: Красный
#   РАСХОД: {'input_tokens': 227, 'output_tokens': 162, 'total_tokens': 389, 'input_token_details': {}, 'output_token_details': {}}

Картинка восемь на восемь точек, записанные строкой base64 в файле, чтобы пример не зависел от чужого сайта и работал без интернета.

Смотрите на первые две группы вывода. Блоки переданы в аргументе content_blocks, а печатаются и content, и content_blocks. Аргумент content_blocks при создании сообщения заполняет и content тоже. Смысл в том, что у вас появляется типизированный способ собрать содержимое вместо ручной сборки словарей формата провайдера. По выводу видно, что там лежат те же стандартные блоки.

Ветка if isinstance(message.content, str) страхует от случая, когда в content окажется строка. Сейчас туда ложатся те же стандартные блоки, но в каком виде content заполняется при сборке из content_blocks, документация не описывает.

Третья группа вывода зависит от провайдера.

Исход первый: модель картинку приняла. Он и стоит в выводе выше: печатается ответ модели и расход токенов. Вход при этом заметно больше, чем на текстовом вопросе такой же длины. Картинка тоже считается токенами, и счёт идёт по правилам провайдера.

Исход второй: провайдер отказал. Тогда срабатывает ветка except и печатается имя ошибки вместе с её текстом. Такой отказ выглядит, например, так: OpenAIModelNotFoundError, код 404 и пояснение, что точки входа с поддержкой картинки не нашлось. Запрос при этом собран и отправлен, отказ приходит от провайдера, у которого за этим именем модели стоит текстовая точка входа.

Какой исход придёт у вас, зависит от имени модели в MODEL_NAME. Поэтому в коде, который отправляет файлы, ветка на отказ нужна всегда. А спросить о поддержке заранее, до первого запроса, позволяет профиль модели и поле image_inputs в нём, это урок 4.

И последнее про формат. Тот же самый смысл можно записать в формате конкретного провайдера, старый код так и делает: блок с типом image_url, внутри которого словарь с ключом url. Такая запись работает и сейчас, потому что content принимает структуры провайдера как есть. Цена это переносимость: со сменой провайдера такие блоки придётся переписывать, а стандартные, нет.

Сериализация сообщений

Всё сделанное до сих пор хранится только до конца работы программы. Разговор, который продолжится завтра, надо где-то хранить, и хранить его придётся вам.

Сообщение это объект Python, и класть его в базу как есть неудобно. Для перевода в обычные структуры и обратно есть два способа: dumpd превращает сообщение в словарь, load собирает из словаря объект сообщения. Оба лежат в langchain_core.load.

Пример 08_serialization.py

import json
import warnings
from pathlib import Path

from langchain.messages import HumanMessage, SystemMessage
from langchain_core.load import dumpd, load

from course_model import build_model

HISTORY_FILE = Path(__file__).with_name("dialogue.json")

model = build_model(temperature=0)

# День первый: один ход разговора.
history = [
    SystemMessage("Вы отвечаете одним предложением, без вступлений."),
    HumanMessage("Я собираю ноутбук для монтажа видео, бюджет 120 тысяч. Что важнее всего?"),
]
history.append(model.invoke(history))

print("ДО СОХРАНЕНИЯ:", [type(message).__name__ for message in history])
print("ОТВЕТ МОДЕЛИ: ", history[-1].text.strip())
print()

# История уходит в файл: dumpd отдаёт словарь, json.dumps пишет его на диск.
HISTORY_FILE.write_text(
    json.dumps([dumpd(message) for message in history], ensure_ascii=False, indent=2),
    encoding="utf-8",
)

print("РАЗМЕР ФАЙЛА:", HISTORY_FILE.stat().st_size, "байт")
print("ВОПРОС ПОЛЬЗОВАТЕЛЯ В ВИДЕ СЛОВАРЯ:")
print(json.dumps(dumpd(history[1]), ensure_ascii=False, indent=2))
print()


def restore(items, **kwargs):
    """Собирает сообщения из словарей и возвращает их вместе с предупреждениями."""
    with warnings.catch_warnings(record=True) as caught:
        warnings.simplefilter("once")
        messages = [load(item, **kwargs) for item in items]
    return messages, caught


def print_warnings(title, caught):
    """Печатает предупреждения без абсолютного пути: имя файла и номер строки."""
    print(title)
    if not caught:
        print("  предупреждений нет")
    for warning in caught:
        print(f"  {warning.category.__name__}, {Path(warning.filename).name}:{warning.lineno}")
        print(f"    {warning.message}")


# День второй: другой запуск программы, история берётся из файла.
raw = json.loads(HISTORY_FILE.read_text(encoding="utf-8"))

# Так писать не стоит: список разрешённых классов не задан.
by_default, default_warnings = restore(raw)
print_warnings("ВЫЗОВ БЕЗ allowed_objects", default_warnings)
print()

# Так стоит: разрешены только классы сообщений.
restored, strict_warnings = restore(raw, allowed_objects="messages")
print_warnings('ВЫЗОВ С allowed_objects="messages"', strict_warnings)
print()

print("ПОСЛЕ ВОССТАНОВЛЕНИЯ:", [type(message).__name__ for message in restored])
print(
    "СОДЕРЖИМОЕ СОВПАЛО:",
    [message.content for message in restored] == [message.content for message in history],
)
print(
    "ОБА СПОСОБА ДАЛИ ОДНО И ТО ЖЕ:",
    [message.content for message in by_default] == [message.content for message in restored],
)

restored.append(HumanMessage("Повторите ваш совет одним предложением."))

print("ПРОДОЛЖЕНИЕ РАЗГОВОРА:", model.invoke(restored).text.strip())

# Вывод:
# ДО СОХРАНЕНИЯ: ['SystemMessage', 'HumanMessage', 'AIMessage']
# ОТВЕТ МОДЕЛИ:  Важнее всего мощный процессор (минимум 8 ядер) и дискретная видеокарта с объёмом видеопамяти от 6 ГБ, а также быстрый SSD не менее 512 ГБ и не менее 16 ГБ оперативной памяти.
#
# РАЗМЕР ФАЙЛА: 2137 байт
# ВОПРОС ПОЛЬЗОВАТЕЛЯ В ВИДЕ СЛОВАРЯ:
# {
#   "lc": 1,
#   "type": "constructor",
#   "id": [
#     "langchain",
#     "schema",
#     "messages",
#     "HumanMessage"
#   ],
#   "kwargs": {
#     "content": "Я собираю ноутбук для монтажа видео, бюджет 120 тысяч. Что важнее всего?",
#     "type": "human"
#   }
# }
#
# ВЫЗОВ БЕЗ allowed_objects
#   LangChainBetaWarning, 08_serialization.py:56
#     The function `load` is in beta. It is actively being worked on, so the API may change.
#   LangChainPendingDeprecationWarning, 08_serialization.py:56
#     The default value of `allowed_objects` will change in a future version. Pass an explicit list of allowed classes (or 'messages' for untrusted input that contains only chat messages) to suppress this warning.
#
# ВЫЗОВ С allowed_objects="messages"
#   предупреждений нет
#
# ПОСЛЕ ВОССТАНОВЛЕНИЯ: ['SystemMessage', 'HumanMessage', 'AIMessage']
# СОДЕРЖИМОЕ СОВПАЛО: True
# ОБА СПОСОБА ДАЛИ ОДНО И ТО ЖЕ: True
# ПРОДОЛЖЕНИЕ РАЗГОВОРА: Важнее всего мощный процессор (минимум 8 ядер) и дискретная видеокарта с объёмом видеопамяти от 6 ГБ, а также быстрый SSD не менее 512 ГБ и не менее 16 ГБ оперативной памяти.

Рядом со скриптом появится dialogue.json. Служебные поля видны и в выводе: lc, type и id с путём к классу, по этому пути load собирает сообщение нужным классом.

Две вспомогательные функции в примере, restore и print_warnings, нужны из-за мелочи. Предупреждение Python печатает вместе с абсолютным путём к файлу, а он у каждого свой. Поэтому пример предупреждения не глушит и не пропускает, а ловит и печатает сам, оставляя из адреса имя файла и номер строки.

load создаёт объекты Python и в ходе разбора может вызвать побочные эффекты. Не вызывайте load на данных из недоверенного или непроверенного источника. То есть восстанавливать разговор из своей базы, это нормально. А разбирать этим вызовом то, что прислал пользователь в теле запроса, нельзя.

Вызов load без дополнительных аргументов печатает два предупреждения.

1) LangChainBetaWarning: функция load объявлена бетой, её интерфейс может измениться

2) LangChainPendingDeprecationWarning: значение по умолчанию у параметра allowed_objects в будущей версии сменится, и текст предупреждения предлагает задать список явно

Второе предупреждение касается вашего кода. Сегодня по умолчанию разрешены все классы ядра. Значение по умолчанию сменится, и вызов, написанный без параметра, может перестать собирать то, что собирал раньше. Поэтому в примере два вызова. Первый без параметра, чтобы предупреждение было видно. Второй с явным allowed_objects="messages", и это та форма, которую стоит писать в своём коде.

Если поменять вызовы местами, предупреждение о бете переедет в группу строгого вызова. Декоратор @beta печатает его один раз за процесс, на первом вызове load с любыми аргументами: внутри у него флаг warned. Параметр allowed_objects снимает только предупреждение о смене значения по умолчанию.

allowed_objects это белый список классов, которые вызову разрешено создавать. У него четыре значения.

Значение Что разрешено создавать Когда брать
список классов только перечисленные вами классы самый строгий вариант, для чужих данных
"messages" только классы сообщений чата чужие данные, в которых лежит переписка
"core" классы ядра: сообщения, документы, шаблоны текущее значение по умолчанию, годится для своих данных
"all" всё из таблиц сериализации, включая модели провайдеров только когда источник целиком ваш

Зачем такая строгость. Разбор не читает данные, он создаёт объекты, то есть вызывает конструкторы разрешённых классов с теми аргументами, что записаны в файле. При значении "all" класс модели, собранный из чужого файла, унесёт с собой и адрес base_url из этого файла. Все запросы этой модели уйдут туда, куда указал автор файла. Поэтому сохранённый диалог правильно считать не текстом, а исполняемой конфигурацией. Значение "messages" безопасно ровно потому, что классы сообщений в конструкторе никуда не ходят и ничего не открывают.

Сохранять диалог своим кодом вам придётся редко: у фреймворка есть готовая память сессии, она хранит историю между вызовами. Её разберу в уроке 12.

Распространённые ошибки

Ошибка 1: вызывают text со скобками

# Неправильно: в текущей версии это свойство, а не метод
print(response.text())

Что происходит: код работает, но печатает предупреждение об устаревшем вызове.

Почему так: раньше text был методом, теперь это свойство. Скобки надо убрать, форма с вызовом пока поддерживается и будет удалена в версии 2.

# Правильно
print(response.text)

Ошибка 2: складывают content со строкой

# Неправильно: content не обязан быть строкой
answer = model.invoke("Решите задачу и объясните решение")
print("Ответ: " + answer.content)   # TypeError, если провайдер прислал список блоков

Что происходит: на ответе строкой код работает, а на ответе списком блоков падает с TypeError: can only concatenate str (not "list") to str. Список присылает, например, Anthropic, когда в ответе есть рассуждение. Хуже всего то, что падает он не у вас на отладке, а на первом же провайдере с другим форматом ответа.

Почему так: поле content намеренно слабо типизировано и принимает как строку, так и список блоков.

# Правильно, когда нужен текст
print("Ответ: " + answer.text)

# Правильно, когда нужна структура
for block in answer.content_blocks:
    print(block["type"])

Ошибка 3: отвечают инструментом не на тот вызов

# Неправильно: идентификатор придуман, а не взят из вызова модели
tool_message = ToolMessage(content="Sunny, 72F", tool_call_id="call_1")

Что происходит: зависит от провайдера. На модели курса запрос проходит, и модель отвечает по результату, хотя идентификатор чужой. Полагаться на это нельзя: у другого провайдера такой запрос может не пройти.

Почему так: tool_call_id обязан совпадать с id того вызова, на который вы отвечаете, а провайдер генерирует идентификаторы заново на каждом запросе.

# Правильно: идентификатор берётся из самого вызова
for tool_call in ai_message.tool_calls:
    tool_message = ToolMessage(
        content=run_my_function(**tool_call["args"]),
        tool_call_id=tool_call["id"],
        name=tool_call["name"],
    )

# Ещё правильнее: пусть сообщение соберёт сам инструмент
for tool_call in ai_message.tool_calls:
    tool_message = get_weather.invoke(tool_call)

Ошибка 4: восстанавливают сообщения из того, что прислал пользователь

# Неправильно: данные пришли из запроса, а не из вашей базы
history = [load(item) for item in request.json()["history"]]

Что происходит: вы отдаёте чужим данным право создавать объекты Python внутри вашего процесса.

Почему так: load создаёт объекты и может дать побочные эффекты при разборе. Для чужих данных задавайте allowed_objects явно, а не полагайтесь на значение по умолчанию.

# Правильно: наружу уходит свой узкий формат,
# сообщения собираются здесь же
history = [
    HumanMessage(item["text"]) if item["role"] == "user" else AIMessage(item["text"])
    for item in request.json()["history"]
]

# load вызывается только на своих данных и с явным allowed_objects
restored = [
    load(item, allowed_objects="messages")
    for item in json.loads(HISTORY_FILE.read_text(encoding="utf-8"))
]

Практическое задание

Напишите скрипт message_passport.py, который печатает "паспорт" любого сообщения, и прогоните его на диалоге с вызовом инструмента.

Требования:

1) функция passport(message) печатает пять строк: имя класса, тип поля content, длину text в знаках, список типов блоков из content_blocks, количество вызовов в tool_calls

2) функция не должна падать ни на одном из четырёх типов сообщений. У HumanMessage, SystemMessage и ToolMessage нет поля tool_calls, учтите это через getattr со значением по умолчанию

3) соберите разговор из примера 6 этого урока: вопрос про погоду, ответ модели с вызовом инструмента, результат инструмента, финальный ответ. Первое сообщение соберите объектом HumanMessage (в примере 6 оно словарь), финальный ответ модели допишите в историю сами. Напечатайте паспорт каждого сообщения истории

4) сохраните историю в файл через dumpd, восстановите через load и напечатайте паспорта восстановленных сообщений

5) сравните два списка паспортов и напечатайте одну строку: совпали или нет

Как проверить результат:

1) в выводе четыре паспорта до сохранения и четыре после, и они одинаковые

2) у сообщения модели с вызовом инструмента длина text равна нулю, а вызовов в tool_calls не меньше одного

3) у ToolMessage список типов блоков состоит из одного элемента, и это text

4) скрипт не падает, если модель инструмент не вызвала. Вместо паспорта пустой истории он печатает строку об этом

Подсказка: чтобы не писать разбор типов руками, отбирайте типы блоков выражением [block["type"] for block in message.content_blocks], оно работает для любого сообщения.

Итоги урока

Теперь вы умеете читать сообщение целиком, а не только его текст. Роль отвечает на вопрос, кто говорит. Есть четыре типа сообщений: системное, пользователя, модели и инструмента, записать их можно объектами, словарями или строкой. Содержимое лежит в content и бывает трёх видов: строка, блоки провайдера, стандартные блоки. Текст доступен свойством text, стандартный вид содержимого, свойством content_blocks, а положить стандартные блоки прямо в content можно параметром output_version="v1".

Вопросы из урока 2 закрыты. Пустое содержимое у сообщения модели не ошибка. Просьба вызвать инструмент лежит в отдельном поле tool_calls, а ответ возвращается сообщением ToolMessage, чей tool_call_id обязан совпасть с идентификатором вызова. Содержимое бывает списком, потому что поле content намеренно принимает и строку, и список блоков.

Вы знаете, где искать рассуждение модели, блоки reasoning и счётчик в output_token_details. Знаете, что счётчик заполняет провайдер, а блоки появляются, только если интеграция разбирает рассуждение. Знаете, как положить в сообщение картинку тремя способами и что дальше решает провайдер. Одна модель ответит на картинку, другая откажет ошибкой. Оба исхода ваш код обязан выдержать. И умеете сохранить разговор через dumpd и поднять обратно через load, помня про запрет звать load на чужих данных и про список разрешённых классов в параметре allowed_objects.

Чего в этом уроке не хватило. Модель всё это время собиралась одной функцией с одним параметром temperature, а в примере 4 к нему добавился второй, output_version, и это выглядело как случайность. Между тем модель это настраиваемый компонент со своим профилем ограничений, счётчиком денег, поведением при отказе сети и способом выбираться под задачу на лету.

В уроке 4, "Модель как настраиваемый компонент", разберу параметры и их влияние, профили моделей, настраиваемую модель и динамический выбор модели под задачу. И ещё инструменты на стороне провайдера, ограничение частоты запросов и устойчивость соединения. Там же вернусь к UsageMetadataCallbackHandler из урока 1 и разложу расход по нескольким моделям, когда их в приложении станет больше одной.

Код урока

Примеры этого урока лежат в репозитории курса, папка lesson_03. Закреплённые версии, на которых получен вывод в тексте, лежат в requirements.txt в корне репозитория.


Предыдущий урок: Зачем нужен LangChain

Следующий урок: Модель как настраиваемый компонент ---

Подписывайтесь на мой Telegram канал

Если вам нужен ментор и вы хотите научиться разрабатывать AI агентов, пишите, обсудим условия

Авторизуйтесь, чтобы оставить комментарий.

Комментариев: 0

Нет комментариев.

Тут может быть ваша реклама

Пишите info@aisferaic.ru

Похожие туториалы