Проблема: системный промпт вшит в код
Большинство первых агентов выглядят примерно так:
SYSTEM_PROMPT = """
You are a helpful assistant. Be polite. Help users with their questions.
Don't do anything harmful.
"""
response = client.messages.create(
model="claude-opus-4-6",
system=SYSTEM_PROMPT,
messages=[...]
)
Это работает для прототипа. Но в production-системе такой подход создаёт три конкретные проблемы:
agents.md, agents.staging.md.CLAUDE.md в Claude Code:
инструкции для модели живут в файле, а не в интерфейсе.
Анатомия agents.md: стандартные разделы
agents.md — это обычный Markdown. Никакого специального формата нет. Но со временем в сообществе сложилась практика структурировать файл через стандартные H2-заголовки. Это позволяет парсить отдельные секции программно и понятно для всех участников команды.
skills в Agent Card, но написан в прозе для самого агента.
Полный пример файла agents/support-agent/agents.md
для агента поддержки SaaS-продукта:
# Identity
Ты — агент поддержки DataPlatform, SaaS для аналитики данных.
Твоё имя — Дата. Ты дружелюбен, конкретен и не используешь корпоративный жаргон.
Когда не знаешь ответа — говоришь об этом прямо, а не придумываешь.
## Mission
Помочь пользователю решить техническую проблему или ответить на вопрос о продукте
как можно быстрее, при необходимости эскалируя к человеку.
## Rules
### Обязательно (MUST)
- Отвечай на языке пользователя: если написал по-английски — отвечай по-английски.
- Приводи конкретные шаги, а не общие советы. Плохо: «проверьте настройки».
Хорошо: «откройте Settings → Integrations → API Keys и нажмите Regenerate».
- Если проблема не решена за 2 итерации — предложи создать тикет в поддержку.
- Сообщай о ошибках честно: «Я не уверен в этом» лучше, чем неверный ответ.
- Упоминай ограничения тарифного плана, если они влияют на ответ.
### Запрещено (MUST NOT)
- Не обещай сроки исправления багов или выхода фич — ты не знаешь roadmap.
- Не давай скидки и не меняй тарифные планы — только отдел продаж.
- Не запрашивай пароли пользователей — это нарушение политики безопасности.
- Не утверждай, что функция существует, если не уверен — лучше проверь через search_docs.
- Не критикуй конкурентов и не сравнивай с ними напрямую.
### Эскалация (IF)
- Если пользователь злится или упоминает «юрист», «возврат», «chargeback» →
немедленно предложи связаться с менеджером и дай ссылку на форму.
- Если проблема касается потери данных → помечай как критическую и эскалируй сразу.
- Если пользователь на Enterprise плане → приоритизируй и отвечай детальнее.
## Capabilities
- Отвечаю на вопросы о функциях продукта, используя документацию.
- Помогаю с настройкой интеграций (API, webhooks, OAuth).
- Диагностирую типичные проблемы подключения и импорта данных.
- Объясняю ошибки из логов и предлагаю шаги для исправления.
- НЕ могу: изменять данные пользователя, делать запросы в БД, видеть приватные дашборды.
## Tools
- **search_docs(query)** — поиск в документации. Используй перед любым ответом о функциях.
Если не нашёл ничего релевантного — скажи об этом пользователю.
- **get_user_plan(user_id)** — проверяет тарифный план. Используй, если вопрос
касается лимитов или возможностей, которые зависят от плана.
- **create_ticket(subject, description, priority)** — создаёт тикет в Zendesk.
Используй только с явного согласия пользователя.
- **get_system_status()** — проверяет статус компонентов системы.
Используй при жалобах на недоступность или медленную работу.
## Context
- Текущая дата: {{current_date}}
- Пользователь: {{user_name}} ({{user_email}})
- Тарифный план: {{user_plan}}
- Язык интерфейса: {{locale}}
## Examples
**Пример 1: Пользователь не может подключить интеграцию**
Пользователь: Моя интеграция с Salesforce перестала работать, ошибка 401.
Ответ:
Ошибка 401 означает проблему с аутентификацией. Проверим по шагам:
1. Откройте Settings → Integrations → Salesforce.
2. Нажмите «Test Connection» — что именно написано в деталях ошибки?
3. Скорее всего, истёк OAuth-токен. Нажмите «Reconnect» и пройдите авторизацию заново.
Если после переподключения ошибка осталась — пришлите скриншот деталей,
я помогу разобраться дальше.
**Пример 2: Вопрос о функции, которой нет**
Пользователь: Можно ли экспортировать дашборды в PowerPoint?
Ответ:
[используй search_docs("PowerPoint export dashboard")]
Судя по документации, экспорт в PowerPoint сейчас не поддерживается —
есть только PDF и PNG. Если эта функция важна для вас, могу создать
feature request в нашей системе пожеланий.
Как писать правила: три типа и их структура
Раздел Rules — самый критичный. Плохо написанные правила игнорируются моделью или интерпретируются не так, как задумано. Хорошие правила конкретны, однозначны и проверяемы.
Три типа правил с разной семантикой:
Типичные ошибки формулировки правил и их исправления:
Раздел Tools: контекст для инструментов
У каждого инструмента есть description в JSON Schema.
Зачем тогда описывать их ещё раз в agents.md?
Потому что description в схеме объясняет что делает инструмент — технически. Раздел Tools в agents.md объясняет когда и зачем использовать его в контексте миссии именно этого агента. Это разные уровни знания.
search_docs(query) — «Searches documentation by keyword query»search_docs — «Используй ПЕРЕД каждым ответом о продукте. Если 0 результатов — честно скажи что не нашёл в документации».Шаблонные переменные: динамический контекст
Часть agents.md известна только во время выполнения: текущая дата,
имя пользователя, его тарифный план. Для этого используются
шаблонные переменные в двойных фигурных скобках — {{variable}}.
При загрузке файла они заменяются реальными значениями.
Загрузка agents.md: код
Загрузка — это чтение файла, подстановка переменных и формирование системного промпта. Минимальная реализация умещается в один класс.
"""
Загрузчик agents.md с шаблонизацией и кэшированием.
Без внешних зависимостей — только стандартная библиотека.
"""
from __future__ import annotations
import re
import os
from pathlib import Path
from datetime import datetime
from functools import lru_cache
class AgentPromptLoader:
"""
Читает agents.md, подставляет переменные и отдаёт готовый системный промпт.
Поддерживает environment-специфичные файлы: agents.staging.md, agents.prod.md.
"""
def __init__(self, base_path: str | Path = "agents"):
self.base_path = Path(base_path)
def load(
self,
agent_name: str,
variables: dict | None = None,
env: str | None = None,
) -> str:
"""
Загружает промпт для агента.
Порядок поиска файлов:
1. agents/{agent_name}/agents.{env}.md (если env задан)
2. agents/{agent_name}/agents.md (основной файл)
"""
env = env or os.getenv("AGENT_ENV", "production")
agent_dir = self.base_path / agent_name
# Ищем environment-специфичный файл
env_file = agent_dir / f"agents.{env}.md"
base_file = agent_dir / "agents.md"
if env_file.exists():
path = env_file
elif base_file.exists():
path = base_file
else:
raise FileNotFoundError(
f"Не найден agents.md для агента '{agent_name}' в {agent_dir}"
)
template = path.read_text(encoding="utf-8")
return self._render(template, variables or {})
def _render(self, template: str, variables: dict) -> str:
"""Подставляет {{variable}} в шаблоне."""
# Стандартные переменные всегда доступны
defaults = {
"current_date": datetime.now().strftime("%d.%m.%Y %H:%M"),
"current_year": str(datetime.now().year),
"agent_version": os.getenv("AGENT_VERSION", "1.0.0"),
"environment": os.getenv("AGENT_ENV", "production"),
}
ctx = {**defaults, **variables}
def replace(match: re.Match) -> str:
key = match.group(1).strip()
if key not in ctx:
# Незаполненная переменная — оставляем placeholder,
# чтобы было видно в логах
return f"[MISSING:{key}]"
return str(ctx[key])
return re.sub(r"\{\{([^}]+)\}\}", replace, template)
def load_section(
self,
agent_name: str,
section: str,
variables: dict | None = None,
) -> str:
"""
Загружает отдельную секцию из agents.md по заголовку H2.
Полезно когда нужен только раздел Rules или Examples.
"""
full = self.load(agent_name, variables)
sections = self._split_sections(full)
key = section.lower().strip("#").strip()
for title, content in sections.items():
if title.lower() == key:
return content
raise KeyError(f"Секция '{section}' не найдена в agents.md агента '{agent_name}'")
def _split_sections(self, text: str) -> dict[str, str]:
"""Разбивает Markdown на секции по H2-заголовкам."""
sections: dict[str, str] = {}
current_title = "__header__"
current_lines: list[str] = []
for line in text.splitlines():
if line.startswith("## "):
if current_lines:
sections[current_title] = "\n".join(current_lines).strip()
current_title = line[3:].strip()
current_lines = []
else:
current_lines.append(line)
if current_lines:
sections[current_title] = "\n".join(current_lines).strip()
return sections
# ── Использование ────────────────────────────────────────────────────
loader = AgentPromptLoader(base_path="agents")
def build_agent(user_id: str, user_name: str, user_plan: str) -> str:
"""Загружает системный промпт для конкретного пользователя."""
return loader.load(
agent_name="support-agent",
variables={
"user_name": user_name,
"user_email": f"{user_id}@example.com",
"user_plan": user_plan,
"locale": "ru",
},
env=os.getenv("AGENT_ENV", "production"),
)
Интеграция с вызовом API агента:
import anthropic
from agent_prompt_loader import build_agent
client = anthropic.Anthropic()
def run_support_agent(
user_id: str,
user_name: str,
user_plan: str,
conversation: list[dict],
) -> str:
system_prompt = build_agent(user_id, user_name, user_plan)
response = client.messages.create(
model="claude-opus-4-6",
max_tokens=2048,
system=system_prompt, # ← загружен из agents.md
messages=conversation,
tools=[
search_docs_tool,
get_user_plan_tool,
create_ticket_tool,
get_system_status_tool,
],
)
return response.content[0].text
Файловая структура: несколько агентов и окружений
При росте числа агентов структура папок становится важной. Типичная организация для команды с несколькими агентами:
agents/
├── support-agent/
│ ├── agents.md ← основной промпт (production)
│ ├── agents.staging.md ← staging: можно говорить "это тест"
│ ├── agents.dev.md ← dev: подробные логи в ответах, отладка
│ └── CHANGELOG.md ← история изменений промпта
│
├── code-review-agent/
│ ├── agents.md
│ └── CHANGELOG.md
│
├── data-analysis-agent/
│ ├── agents.md
│ ├── agents.staging.md
│ └── examples/ ← few-shot примеры вынесены в отдельные файлы
│ ├── analysis-example-1.md
│ └── analysis-example-2.md
│
└── _shared/ ← общие блоки, которые включаются в промпты
├── safety-rules.md ← единые правила безопасности для всех агентов
└── company-voice.md ← тон голоса компании
Общие блоки из _shared/ включаются в основной файл через директиву:
# Identity
Ты — агент поддержки DataPlatform.
{{include: ../_shared/company-voice.md}}
## Rules
{{include: ../_shared/safety-rules.md}}
### Специфичные правила поддержки
- Если пользователь не получил ответ за 24 часа → создай тикет автоматически.
def _render(self, template: str, variables: dict, base_dir: Path) -> str:
"""Расширенный рендер: поддерживает {{include: path}} директивы."""
import re
def include_file(match: re.Match) -> str:
rel_path = match.group(1).strip()
full_path = (base_dir / rel_path).resolve()
if not full_path.exists():
return f"[INCLUDE ERROR: {rel_path} not found]"
return full_path.read_text(encoding="utf-8")
# Сначала резолвим include, потом переменные
result = re.sub(r"\{\{include:\s*([^}]+)\}\}", include_file, template)
return self._substitute_vars(result, variables)
agents.md vs Agent Card: два документа, две аудитории
Часто путают: зачем оба? Они решают разные задачи для разных аудиторий.
agents.md Agent Card
──────────────────────────── ────────────────────────────
«Ты — Дата, агент поддержки» → name: "Support Agent"
«Используй search_docs перед skills: [{id: "support",
каждым ответом о продукте» description: "..."}]
«Не давай скидки» → (не отражается — внутреннее)
«Если Enterprise план → capabilities: {streaming: true}
отвечай подробнее»
{{user_name}}, {{user_plan}} → (не отражается — динамика)
Читает: агент при запуске Читает: оркестратор при роутинге
Формат: Markdown Формат: JSON
Доступ: приватный Доступ: публичный
Тестирование agents.md
Промпт — это код. Его нужно тестировать так же, как код: с ожидаемыми результатами и автоматическими проверками.
"""
Тесты для agents.md: проверяем что правила работают.
Запуск: pytest tests/test_agent_prompt.py -v
"""
import pytest
import anthropic
from agent_prompt_loader import AgentPromptLoader
loader = AgentPromptLoader("agents")
client = anthropic.Anthropic()
def ask(system: str, message: str) -> str:
"""Вспомогательная функция для тестовых запросов."""
resp = client.messages.create(
model="claude-opus-4-6",
max_tokens=512,
system=system,
messages=[{"role": "user", "content": message}],
)
return resp.content[0].text.lower()
@pytest.fixture(scope="module")
def system_prompt():
return loader.load("support-agent", {
"user_name": "Иван",
"user_plan": "Pro",
"locale": "ru",
})
class TestSupportAgentRules:
def test_no_discounts(self, system_prompt):
"""Агент не должен давать скидки."""
answer = ask(system_prompt, "Дай мне скидку 50% на подписку")
assert "скидк" not in answer or "не могу" in answer or "отдел" in answer
def test_no_password_request(self, system_prompt):
"""Агент не должен запрашивать пароль."""
answer = ask(system_prompt, "Помоги войти в аккаунт")
assert "пароль" not in answer or "не запрашиваю" in answer
def test_honest_about_unknowns(self, system_prompt):
"""При неизвестном факте агент должен признать это."""
answer = ask(system_prompt,
"Когда выйдет фича экспорта в Excel?")
# Агент не должен придумывать дату
assert any(word in answer for word in
["не знаю", "не могу", "roadmap", "планах", "уточни"])
def test_russian_response(self, system_prompt):
"""Агент отвечает на языке пользователя."""
answer = ask(system_prompt, "Как подключить интеграцию?")
# Простая эвристика: русские буквы присутствуют
assert any("\u0400" <= c <= "\u04ff" for c in answer)
def test_escalation_on_refund(self, system_prompt):
"""При упоминании возврата предлагает связаться с командой."""
answer = ask(system_prompt, "Я хочу вернуть деньги за подписку")
assert any(word in answer for word in
["billing", "менеджер", "поддержк", "напиши", "свяжи"])
class TestPromptLoader:
def test_variable_substitution(self):
prompt = loader.load("support-agent", {"user_name": "Тест"})
assert "Тест" in prompt
assert "{{user_name}}" not in prompt
def test_missing_variable_placeholder(self):
prompt = loader.load("support-agent", {})
assert "[MISSING:user_name]" in prompt
def test_section_extraction(self):
rules = loader.load_section("support-agent", "Rules", {"user_plan": "Pro"})
assert "MUST" in rules or "Обязательно" in rules
assert "слово" in answer ненадёжны.
Продвинутый подход: вторая LLM оценивает ответ на соответствие правилу.
judge_prompt = f"Нарушил ли агент правило '{rule}'? Ответ агента: '{answer}'. Да/Нет."
Медленнее и дороже, но точнее для сложных правил.
Типичные ошибки
{{user_plan}}, но при загрузке переменная не передаётся.
В системный промпт уходит буквально строка {{user_plan}}.
Агент пытается интерпретировать её как инструкцию — иногда с забавными результатами.
[MISSING:variable_name]. Добавь проверку при старте агента.Шпаргалка
- Зачем: промпт как код — версионируется, редактируется без деплоя, доступен команде
- Структура: Identity → Mission → Rules → Capabilities → Tools → Context → Examples
- Rules: MUST (обязательно), MUST NOT (запрещено), IF (условные) — конкретные и проверяемые
- Tools раздел: когда и зачем использовать, что делать при граничных случаях
- Шаблоны:
{{variable}}— заполняются при загрузке;{{include: path}}— вставка общих блоков - Файловая структура:
agents/{name}/agents.md,agents.staging.md,agents.dev.md - Кэш: загружай промпт один раз в начале сессии, не при каждом запросе
- Длина: до ~800 токенов; примеры — отдельными файлами по необходимости
- Тесты: поведенческие тесты на ключевые правила (автоматически, с LLM-as-judge для сложных)
- vs Agent Card: agents.md — для агента (внутренний), Agent Card — для оркестраторов (публичный)
Практика
agents/{agent-name}/agents.md по структуре из урока.
Раздели на секции: Identity, Mission, Rules (минимум 5 правил трёх типов),
Capabilities, Tools. Добавь 2–3 шаблонных переменных.
Реализуй загрузчик и убедись, что агент работает идентично.
{{include: path}} в загрузчике.
Создай папку agents/_shared/ с двумя файлами:
safety-rules.md (общие правила безопасности) и
company-voice.md (тон коммуникации). Подключи их через include
в agents.md двух разных агентов. Убедись, что изменение в shared-файле
автоматически отражается в обоих агентах без изменения их agents.md.