Урок 3. Структурированный вывод через инструменты
Один из самых неожиданных способов применять инструменты — заставить Claude отвечать строго структурированным содержимым, например JSON. Ситуаций море: извлечь сущности из текста, свести данные в таблицу, оценить тональность, классифицировать документ.
Можно просто попросить: «ответь в JSON». Обычно сработает — но потом придётся выковыривать JSON из большой строки с текстом вокруг и каждый раз проверять, что формат ровно тот, который вы ждали. Модель может добавить вступление, обернуть ответ в markdown-блок, переименовать поле, вернуть число строкой.
Идея: инструмент как схема
В прошлом уроке мы дали Claude калькулятор. Когда он захотел его вызвать, в ответе пришло вот такое:
{
'operand1': 1984135,
'operand2': 9343116,
'operation': 'multiply'
}
Подозрительно похоже на JSON, правда?
Если нам нужен структурированный JSON — достаточно описать инструмент, который задаёт нужную форму данных, и рассказать о нём Claude. Всё. Claude ответит, думая, что «вызывает инструмент», а нас интересует только сам структурированный ответ.
Чем это отличается от прошлого урока? Там мы давали Claude доступ к инструменту, Claude хотел его вызвать — и мы действительно вызывали функцию под ним. Здесь мы «обманываем» Claude: рассказываем про инструмент, но никакой функции за ним нет. Инструмент нужен только как способ навязать форму ответа.
Почему определение инструмента даёт более надёжный JSON, чем просьба «ответь в JSON» обычным текстом?
Пример: анализ тональности
Начнём с простого. Допустим, мы хотим, чтобы Claude оценил тональность текста и вернул объект такой формы:
{
"negative_score": 0.6,
"neutral_score": 0.3,
"positive_score": 0.1
}
Всё, что нужно — описать эту форму через JSON Schema в определении инструмента:
tools = [
{
"name": "print_sentiment_scores",
"description": "Prints the sentiment scores of a given text.",
"input_schema": {
"type": "object",
"properties": {
"positive_score": {"type": "number", "description": "The positive sentiment score, ranging from 0.0 to 1.0."},
"negative_score": {"type": "number", "description": "The negative sentiment score, ranging from 0.0 to 1.0."},
"neutral_score": {"type": "number", "description": "The neutral sentiment score, ranging from 0.0 to 1.0."}
},
"required": ["positive_score", "negative_score", "neutral_score"]
}
}
]
Теперь рассказываем Claude про инструмент и явно просим им воспользоваться — чтобы он точно его вызвал:
from anthropic import Anthropic
from dotenv import load_dotenv
import json
load_dotenv()
client = Anthropic()
tweet = "I'm a HUGE hater of pickles. I actually despise pickles. They are garbage."
query = f"""
<text>
{tweet}
</text>
Only use the print_sentiment_scores tool.
"""
response = client.messages.create(
model="claude-sonnet-5",
max_tokens=4096,
tools=tools,
messages=[{"role": "user", "content": query}]
)
В ответе приходит сообщение со stop_reason='tool_use', а внутри — блок ToolUseBlock. Важная часть — его поле input:
input={'positive_score': 0.0, 'negative_score': 0.791, 'neutral_score': 0.209}
Claude «думает», что вызывает инструмент, который дальше как-то использует эти оценки. На самом деле мы просто вынимаем данные и превращаем их в JSON:
import json
json_sentiment = None
for content in response.content:
if content.type == "tool_use" and content.name == "print_sentiment_scores":
json_sentiment = content.input
break
if json_sentiment:
print("Sentiment Analysis (JSON):")
print(json.dumps(json_sentiment, indent=2))
else:
print("No sentiment analysis found in the response.")
Работает. Осталось завернуть это в переиспользуемую функцию, которая принимает твит или статью и возвращает разбор тональности:
def analyze_sentiment(content):
query = f"""
<text>
{content}
</text>
Only use the print_sentiment_scores tool.
"""
response = client.messages.create(
model="claude-sonnet-5",
max_tokens=4096,
tools=tools,
messages=[{"role": "user", "content": query}]
)
json_sentiment = None
for content in response.content:
if content.type == "tool_use" and content.name == "print_sentiment_scores":
json_sentiment = content.input
break
if json_sentiment:
print("Sentiment Analysis (JSON):")
print(json.dumps(json_sentiment, indent=2))
else:
print("No sentiment analysis found in the response.")
Дальше можно скормить ей что угодно: analyze_sentiment("OMG I absolutely love taking bubble baths soooo much!!!!") или analyze_sentiment("Honestly I have no opinion on taking baths").
Принуждение через tool_choice
Пока мы «заставляем» Claude вызвать print_sentiment_scores промптом: пишем Only use the print_sentiment_scores tool. Обычно это срабатывает, но есть способ надёжнее — параметр tool_choice:
tool_choice={"type": "tool", "name": "print_sentiment_scores"}
Так мы говорим Claude, что он обязан ответить вызовом именно этого инструмента. Добавляем в функцию:
response = client.messages.create(
model="claude-sonnet-5",
max_tokens=4096,
tools=tools,
tool_choice={"type": "tool", "name": "print_sentiment_scores"},
messages=[{"role": "user", "content": query}]
)
Подробнее tool_choice разберём в одном из следующих уроков.
Что делает tool_choice={"type": "tool", "name": "print_sentiment_scores"}?
Пример: извлечение сущностей
Тот же приём — но теперь достаём из текста людей, организации и локации:
tools = [
{
"name": "print_entities",
"description": "Prints extract named entities.",
"input_schema": {
"type": "object",
"properties": {
"entities": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "The extracted entity name."},
"type": {"type": "string", "description": "The entity type (e.g., PERSON, ORGANIZATION, LOCATION)."},
"context": {"type": "string", "description": "The context in which the entity appears in the text."}
},
"required": ["name", "type", "context"]
}
}
},
"required": ["entities"]
}
}
]
text = "John works at Google in New York. He met with Sarah, the CEO of Acme Inc., last week in San Francisco."
query = f"""
<document>
{text}
</document>
Use the print_entities tool.
"""
response = client.messages.create(
model="claude-sonnet-5",
max_tokens=4096,
tools=tools,
messages=[{"role": "user", "content": query}]
)
json_entities = None
for content in response.content:
if content.type == "tool_use" and content.name == "print_entities":
json_entities = content.input
break
if json_entities:
print("Extracted Entities (JSON):")
print(json.dumps(json_entities, indent=2))
else:
print("No entities found in the response.")
Приём тот же: рассказываем Claude про инструмент, чтобы получить ответ нужной формы, потом вынимаем данные — и готово. И помните: в таком сценарии полезно явно сказать, что мы хотим вызова конкретного инструмента — Use the print_entities tool.
Пример посложнее: разбор статьи
Возьмём задачу поинтереснее. С помощью Python-пакета wikipedia вытянем целую статью и попросим Claude вернуть:
- основной предмет статьи;
- краткое содержание;
- список ключевых слов и тем;
- список категорий (развлечения, политика, бизнес и т. д.) с оценкой, насколько сильно статья относится к каждой.
Для статьи про Уолта Диснея ожидаемый результат выглядел бы примерно так:
{
"subject": "Walt Disney",
"summary": "Walter Elias Disney was an American animator, film producer, and entrepreneur...",
"keywords": [
"Walt Disney",
"animation",
"film producer",
"entrepreneur",
"Disneyland",
"theme parks",
"television"
],
"categories": [
{"name": "Entertainment", "score": 0.9},
{"name": "Business", "score": 0.7},
{"name": "Technology", "score": 0.6}
]
}
Стратегия прежняя: описываем инструмент, который «подсказывает» форму ответа. Не забудьте pip install wikipedia.
import wikipedia
# определение инструмента
tools = [
{
"name": "print_article_classification",
"description": "Prints the classification results.",
"input_schema": {
"type": "object",
"properties": {
"subject": {
"type": "string",
"description": "The overall subject of the article",
},
"summary": {
"type": "string",
"description": "A paragaph summary of the article"
},
"keywords": {
"type": "array",
"items": {
"type": "string",
"description": "List of keywords and topics in the article"
}
},
"categories": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "The category name."},
"score": {"type": "number", "description": "The classification score for the category, ranging from 0.0 to 1.0."}
},
"required": ["name", "score"]
}
}
},
"required": ["subject", "summary", "keywords", "categories"]
}
}
]
# функция, которая генерирует JSON по теме статьи
def generate_json_for_article(subject):
page = wikipedia.page(subject, auto_suggest=True)
query = f"""
<document>
{page.content}
</document>
Use the print_article_classification tool. Example categories are Politics, Sports, Technology, Entertainment, Business.
"""
response = client.messages.create(
model="claude-haiku-4-5",
max_tokens=4096,
tools=tools,
messages=[{"role": "user", "content": query}]
)
json_classification = None
for content in response.content:
if content.type == "tool_use" and content.name == "print_article_classification":
json_classification = content.input
break
if json_classification:
print("Text Classification (JSON):")
print(json.dumps(json_classification, indent=2))
else:
print("No text classification found in the response.")
Проверить можно на чём угодно: generate_json_for_article("Jeff Goldblum"), generate_json_for_article("Octopus"), generate_json_for_article("Herbert Hoover").
Обратите внимание на вложенность: keywords — массив строк, categories — массив объектов со своими required-полями. JSON Schema позволяет описать структуру любой глубины, и Claude будет ей следовать.
Что мы делаем с ответом Claude в этом приёме?
Упражнения
Упражнение 3.1 — Переводчик
По той же стратегии напишите функцию translate, которая принимает слово или фразу и возвращает структурированный JSON: исходная фраза на английском плюс переводы на испанский, французский, японский и арабский.
Вызов translate("how much does this cost") должен давать примерно такое:
{
"english": "how much does this cost",
"spanish": "¿cuánto cuesta esto?",
"french": "combien ça coûte?",
"japanese": "これはいくらですか",
"arabic": "كم تكلفة هذا؟"
}
Подсказка: чтобы результат печатался читаемо, а не в виде escape-последовательностей, используйте print(json.dumps(translations_from_claude, ensure_ascii=False, indent=2)).
Решение упражненияСначала попробуйте сами — потом сверьтесь
tools = [
{
"name": "print_translations",
"description": "Prints the translations of a given phrase.",
"input_schema": {
"type": "object",
"properties": {
"english": {"type": "string", "description": "The original phrase in English."},
"spanish": {"type": "string", "description": "The phrase translated into Spanish."},
"french": {"type": "string", "description": "The phrase translated into French."},
"japanese": {"type": "string", "description": "The phrase translated into Japanese."},
"arabic": {"type": "string", "description": "The phrase translated into Arabic."}
},
"required": ["english", "spanish", "french", "japanese", "arabic"]
}
}
]
def translate(phrase):
query = f"""
<phrase>
{phrase}
</phrase>
Use the print_translations tool.
"""
response = client.messages.create(
model="claude-sonnet-5",
max_tokens=4096,
tools=tools,
tool_choice={"type": "tool", "name": "print_translations"},
messages=[{"role": "user", "content": query}]
)
for content in response.content:
if content.type == "tool_use" and content.name == "print_translations":
print(json.dumps(content.input, ensure_ascii=False, indent=2))
return content.input
print("No translations found in the response.")
Функции print_translations в коде нет и не нужно: мы забираем content.input и работаем с ним как с обычным словарём. tool_choice здесь страхует от ситуации, когда Claude решит ответить просто текстом.
Упражнение 3.2 — Схема для карточки товара
Опишите инструмент, который извлекает из свободного описания товара структуру: название (title), цену числом (price), валюту (currency) и массив характеристик features — каждая с полями name и value. Проверьте на любом тексте объявления.
Решение упражненияСначала попробуйте сами — потом сверьтесь
tools = [
{
"name": "print_product_card",
"description": "Prints structured product data extracted from a description.",
"input_schema": {
"type": "object",
"properties": {
"title": {"type": "string", "description": "The product title."},
"price": {"type": "number", "description": "The numeric price of the product."},
"currency": {"type": "string", "description": "The currency code, e.g. USD, EUR, KZT."},
"features": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "The feature name."},
"value": {"type": "string", "description": "The feature value."}
},
"required": ["name", "value"]
}
}
},
"required": ["title", "price", "currency", "features"]
}
}
]
def extract_product(description):
query = f"""
<document>
{description}
</document>
Use the print_product_card tool.
"""
response = client.messages.create(
model="claude-sonnet-5",
max_tokens=4096,
tools=tools,
tool_choice={"type": "tool", "name": "print_product_card"},
messages=[{"role": "user", "content": query}]
)
for content in response.content:
if content.type == "tool_use" and content.name == "print_product_card":
print(json.dumps(content.input, ensure_ascii=False, indent=2))
return content.input
Ключевое здесь — "type": "number" у цены: так вы получите число, а не строку «1 990 ₸». И описания полей — они работают как инструкции для модели, не ленитесь их писать.
Что запомнить
- Определение инструмента — это заодно и схема ответа. Вызов инструмента приходит уже в нужной структуре.
- Функции под инструментом может не быть вообще: нас интересует только поле
inputу блокаtool_use. - В промпте полезно явно написать: «используй такой-то инструмент». Надёжнее — параметр
tool_choice. - JSON Schema описывает вложенные структуры любой глубины: массивы, объекты, обязательные поля.
- Описания полей в схеме — это инструкции для модели. Чем точнее, тем предсказуемее результат.
Дочитали и сделали упражнения? Зафиксируйте прогресс — отметка сохранится в вашем браузере.