LLM-агент, единственный код-консьюмер biomarker-graph в текущей архитектуре. Первый шаг biomarker-analysis pipeline (biomarker-analysis-pipeline): Diagnostician → Retriever → Reasoner → Writer / Personalizer. Получает контекст пациента, ходит в граф через tool-calls, возвращает структурированный «план обогащения» (DiagnosticPlan) — список биомаркеров с указанием, какие связанные параметры / диагнозы / лекарства / преаналитические факторы стоит достать из FHIR. Сам по себе не диагностирует и не интерпретирует — это knowledge-planner, не «диагностик» в клиническом смысле.
Carry-over: имя «Диагностик» / «Diagnostician Agent» исторически унаследовано из Python-порта, но не отражает функции (планирует обогащение, не диагностирует). Кандидаты на переименование:
KnowledgePlanner,EnrichmentPlanner,RelationsPlanner. Не делаем сейчас — есть зависимости в коде + промптах; вынести в отдельный naming-decision когда дойдут руки.
С появлением сервиса актуальности Диагност нужен и ему: план обогащения используется Ретривером для построения relatedFound*, который классификатор актуальности читает как retrieved per биомаркер (см. biomarker-actuality-integration). То есть Диагност обслуживает двух потребителей — классификатор актуальности (на входе gate’а) и downstream-анализ (Reasoner/Writer на выходе).
Концептуально — связи между сущностями пациента
В медицинской записи пациента лежит список сущностей: наблюдения, диагнозы, рецепты, измерения биомаркеров. Сами по себе они — плоский набор фактов без связей. Но медицина строится вокруг связей: «этот биомаркер сдвигается этим препаратом», «это значение надо читать в свете того диагноза», «эти два показателя вместе указывают на проблему той системы».
Диагност из своих знаний (закодированных в biomarker-graph) говорит: «для биомаркера X с такими-то сущностями может быть связь — если у пациента есть состояние A или препарат P, биомаркер нужно читать в их контексте». Это указание возможных узлов, в свете которых биомаркер должен интерпретироваться, если эти узлы у пациента действительно присутствуют.
Дальше Ретривер идёт в FHIR и проверяет существование узлов у конкретного пациента. Если узел есть — биомаркер надо рассматривать в свете этой сущности; если узла нет — рассматривать в её свете не нужно, она к этому пациенту не относится.
Деление труда. Диагност+Ретривер вместе превращают плоский список сущностей пациента в граф связей, по которому downstream-стадии (классификатор актуальности, Reasoner) могут судить о биомаркере. Диагност — knowledge-source (какие связи бывают в принципе, из медицинских знаний). Ретривер — node-check (какие из этих узлов реально есть у этого пациента, из его FHIR). LLM нужен на стороне знаний о связях; проверка узлов в FHIR — детерминистическая операция.
Терминология рабочая — слова «гипотеза», «верификация» в коде не используются. Концептуально это hypothesis-and-check паттерн (Диагност предлагает кандидатов на связь, Ретривер проверяет их наличие), но окончательные имена ролей пока не зафиксированы.
Каскад Tier 1/2/3
На каждом аномальном наблюдении задача одинаковая — собрать related_* (связанные параметры / диагнозы / лекарства / преаналитические факторы), чтобы Ретривер потом проверил их наличие в FHIR. Меняется только источник этих related_* — отсюда три уровня (tier’ы):
| Tier | evidenceTier | Источник related_* | Трассируемость |
|---|---|---|---|
| 1 | algorithm | специфичный biomarker-entry в графе | quotes / source per ребро |
| 2 | domain_inference | domain-level дефолты в графе | trace до domain-записи |
| 3 | model_knowledge | общие медицинские знания LLM | нет graph-trace |
Названия value’ов отражают способ резолва: algorithm = детерминистическое попадание в конкретную запись, domain_inference = inference через домен, model_knowledge = генерация из LLM-знаний без графа. (Терминология рабочая — наследие Python-порта, потенциально пере-именуется когда Tier 1/2 уйдут на детерминистический lookup.)
Шаг 1 (Tier 1). Резолвится по имени в biomarkers[…]. Domain известен из поля entry.domain (biomarkers["Креатинин"].domain === "renal"). Сегодня попадание в Tier 1 — это LLM-fuzzy-match: имена из лабораторных отчётов («Холестерин общий») не совпадают с keys графа («Total cholesterol»), и LLM перебирает синонимы. При замене ключа графа на LOINC (biomarker-graph-key-loinc) этот шаг стал бы детерминистическим dict.get(loincCode).
Шаг 2 (Tier 2). Если биомаркера в biomarkers[…] нет — определяется его домен, берётся domain-level related_* из domains[domain]. Выбор домена сегодня делает LLM, читая scope-описания доменов. Сделать его deterministic можно через внешнюю классификацию (LOINC→domain сервис), см. biomarker-graph-key-loinc.
Шаг 3 (Tier 3). Если ни биомаркера, ни домена не нашли — LLM генерирует related_* «из общих медицинских знаний». Нежелательный fallback (нет трассируемости), но нужен для биомаркеров вне графа.
Tier как сигнал доверия. Tier 1 > Tier 2 > Tier 3. Downstream-стадии (Reasoner, классификатор актуальности) видят эту метку и могут реагировать — не surface’ить caveat’ы для Tier 3 связей, или явно отмечать пациенту, что связь «из общих знаний», а не из guideline’а. Заполненность Tier 1 vs скатываний на Tier 2/3 — прямая метрика покрытия графа.
Tools
Три инструмента, проброшенные в LLM:
Tools как LLM-обёртка над функциями. Концептуально каждый из этих трёх tools — это функция, которую при детерминистическом lookup’е мы могли бы вызывать сами, напрямую из кода. Сейчас LLM вызывает их за нас, потому что fuzzy-match по именам / domain inference сам по себе ещё не детерминистический. Если Tier 1/2 уедет на LOINC-lookup (biomarker-graph-key-loinc) — tools деградируют до простых function calls в pipeline-коде, агентский цикл LLM уходит. Это конкретный callsite третьей оси — когда LLM в pipeline вообще нужен, и как pipeline эволюционирует от LLM-dispatched к deterministic-dispatched по мере того, как underlying-операции становятся решёнными.
lookup_biomarker(name)— поиск одного биомаркера в графе. Возвращает полные данные (domain,related_*,preanalytical_factors) если найден; иначе — список всех доступных biomarker-ключей (это и есть «не подсказывай, но дай выбор» — LLM сам решит, попробовать ли другой ключ или fallback на домен). Поддерживает batching (параллельные вызовы).lookup_domain(domain)— Tier 2 fallback. Когда биомаркер сам по себе в графе отсутствует — резолвится через свой домен (hematology,thyroid,lipid, …) и тянет domain-levelrelated_*.submit_diagnostic_plan(plan)— терминальное действие. Завершает агентский цикл. Для Tier 1 / Tier 2 элементов LLM не дублирует graph-данные в payload’е —DiagnosticGraphExecutorсам впрыснетrelated_*из кэша lookup’ов поgraph_biomarker_key/graph_domain. Для Tier 3 (model_knowledge) наоборот: впрыскивать нечего, LLM сам обязан положитьrelated_*в payload.
DiagnosticGraphExecutor — stateful per-run (кэш lookup’ов, флаг isFinished после успешного submit’а, счётчик rejection’ов submit’а). Один инстанс на один runDiagnosticianAgent-вызов.
Как это работает
runDiagnosticianAgent(patientContext)
│ abnormal_observations пуст → success: true, empty plan, 0 calls
├─ loadBiomarkerGraph() — lazy-кэш JSON в памяти (см. [[biomarker-graph]])
├─ new DiagnosticGraphExecutor(graph, contextDict) — stateful per run
├─ Mastra Agent(systemPrompt + 3 tools, maxSteps=15)
├─ buildUserMessage(contextDict, executor) — XML-блоки <patient_context>, <knowledge_graph_index>
↓ agentic loop:
1. LLM batches lookup_biomarker(...) параллельно по всем abnormal-observations
2. промахи → lookup_domain(...) Tier 2
3. промахи и здесь → Tier 3 (model_knowledge), related_* пишутся в payload submit'а
4. submit_diagnostic_plan(plan) → executor валидирует input parity → isFinished=true
↓
{ success: true, plan: DiagnosticPlan, toolCallsCount, iterations }
Время работы и стоимость
Cost-measurement framework (Latency p50/p95, token cost input/output, retry / failure rate, phase breakdown) — общий для всех LLM-стадий pipeline’а: Diagnostician, Retriever, классификатор актуальности, Reasoner, Writer-doctor. Описание framework’а вынесено в llm-stage-cost-measurement (placeholder — заполнить при первом систематическом замере). Здесь — Diagnostician-специфика.
Сегодня замеров на проде нет. В отладочных запусках разброс от ~1 минуты до 5+ минут на одном и том же пациенте при том же объёме данных — Gemini-3.1-pro reasoning time нестабилен. До систематических замеров любые выводы остаются гипотетическими.
Что хочется знать про Диагноста специально:
- Phase breakdown — сколько runtime / token cost уходит в Tier 1 fuzzy-match vs Tier 2 domain selection vs Tier 3 generation. Это определяет приоритет оптимизаций: Tier 1 уходит на LOINC-ключ (biomarker-graph-key-loinc), Tier 2 — на LOINC→domain сервис (там же), Tier 3 — единственный residual где LLM незаменим.
- Количество шагов агентского цикла.
MAX_STEPSограничивает сверху — 15 в Mastra-варианте, 25 в raw (комментарий «Gemini occasionally flips into sequential mode на больших пациентах»). По существу хватает 2-3 итераций. Частота попадания в sequential-режим — часть того же cost-breakdown’а на Langfuse-трейсах.
Источник данных — Langfuse-трейсы прод-нагрузки.
I/O-контракт
Вход — PatientContext (бывш. V2PatientContext до Phase A rename):
demographics(age, sex)abnormalObservations[]— отклонения, ради которых вообще запускаем анализ (сname,loinc_code,value,unit,reference_range,interpretation,date,standardized_name,loinc_class)normalObservationNames[]— имена лабораторных биомаркеров со значением «в норме» (в промпт идут только имена, без значений — это контекст «что у пациента вообще измерялось»)activeConditions[]— активные диагнозы (name + onset)activeMedications[]— текущие препараты (name + dosage)
Выход — DiagnosticPlan:
type DiagnosticPlan = {
parameterAssessments: ParameterAssessment[]; // ровно один элемент на каждое abnormal observation (input parity)
};
type ParameterAssessment = {
parameterName: string;
interpretation: string; // H/L/A — verbatim copy от входа, никогда не пересчитывается
clinicalDomain: string;
evidenceTier: "algorithm" | "domain_inference" | "model_knowledge";
graphBiomarkerKey?: string | null; // Tier 1 — биомаркер найден в графе
graphDomain?: string | null; // Tier 2 — домен найден, биомаркер нет
relatedParameters: RelatedItem[];
relatedConditions: RelatedItem[];
relatedMedications: RelatedItem[];
preanalyticalFactors: RelatedItem[];
reasoning: string;
};
type RelatedItem = { name; rationale; quotes?; source? };Контракт input parity — один input → одна assessment-запись. Пропустить отклонение «потому что нерелевантно» запрещено; добавить новое — тоже. Пустой abnormal_observations → ранний выход с пустым планом (без LLM-вызовов).
Failure modes
- Не сабмитнул план за
MAX_STEPS—success: false, plan: null, error: "Agent did not submit a diagnostic plan within max steps."Caller обязан это обработать (сейчас в пайплайне — фейлит весь параметр-анализ для пациента). - Mastra/ai-sdk артефакты ломают prompt —
temperature=0,$schemaполя в request,anyOf+nullобёртки,additionalProperties: false. Это причина существованияdiagnostician.raw.ts(тот же агент, но без Mastra-обёртки, через сыройopenai-клиент). - CJS-bundle +
import.meta.url—diagnostician.mastra.tsрезолвит промпт черезfileURLToPath(import.meta.url). Подanalysis-worker(esbuild strict-mode)import.meta.urlundefined → краш. Та же готча, что и у biomarker-actuality-service. - Sequential-режим Gemini — недетерминированный регресс, обход через
MAX_STEPS=25в raw-варианте.
Открытые вопросы
- Возвращение на Mastra-вариант. Сегодня в проде работает
diagnostician.raw.ts(raw OpenAI SDK без Mastra). Хочется вернуться на Mastra-вариант (observability через Mastra Studio, единая обвязка для всех агентов), но он ломался под schema-инъекциями Mastra/ai-sdk (детали в «Где живёт» ниже). Открытое — когда обвязка станет надёжной (вероятно после зрелого bifrost / fallback-слоя), переключиться обратно и убрать raw. maxOutputTokensне задан — та же проблема, что и у biomarker-actuality-service: открытое окно для mid-decode timeout (gemini-doom-loop).- Мониторинг Tier 3 (
model_knowledge) — инструментирование «пропусков графа». Каждый раз когда Диагност попадает в Tier 3 — это сигнал «графа не хватило для этого биомаркера»; такой counter (per биомаркер / per домен) даёт прямую картину coverage-дыр графа (см. biomarker-graph — coverage map). Сегодня этого инструментирования нет, и непонятно даже частота — Tier 3 = «LLM в runtime решает поверх своих знаний» = ровно тот случай, когда нам особенно нужна верификация.
Вопросы про cost-breakdown и про возможность перевода Tier 1 / Tier 2 на детерминистический lookup — в секции «Время работы и стоимость» выше, плюс на параллельных decision-страницах: biomarker-graph-key-loinc (LOINC-key для Tier 1 + LOINC→domain сервис для Tier 2), biomarker-graph-storage (production-форма графа в целом).
Где живёт
В коде сосуществуют два варианта реализации Диагноста:
- Mastra-вариант (
packages/analysis-core/src/agents/biomarker-analysis/diagnostician.mastra.ts) —runDiagnosticianAgent(...). Использует MastraAgentобёртку поверх ai-sdk; даёт observability через Mastra Studio. - Raw-вариант (
diagnostician.raw.ts) —runDiagnosticianRaw(...). Идёт мимо Mastra, напрямую через сыройopenaiSDK.
Почему два варианта. Mastra-обёртка под определёнными комбинациями inputs ломала prompt-совместимость с Python-портом, который был источником этой реализации:
- принудительно ставила
temperature: 0— Python работал на default’е (1.0), Gemini reasoning-модели подtemperature: 0уходят в loop’ы - инжектировала
$schemaполе в JSON-schema запроса — Vertex такого не ждёт - оборачивала optional поля как
anyOf+nullвместо обычногоOptional— формально валидно, но Vertex-side validator реагировал по-другому - ставила
additionalProperties: false— слишком рестриктивно для Python-style schema
Raw-вариант все эти инъекции обходит, идя прямым OpenAI-клиентом. Это не «лучшая реализация» — это рабочая альтернатива на случай, когда Mastra ломала контракт промпта. В проде сегодня работает raw; вернуться на Mastra-вариант хочется (унифицированная обвязка, observability через Mastra Studio), но требует зрелой bifrost / fallback-обёртки, которая снимет schema-инъекции — см. «Открытые вопросы».
Остальные артефакты:
- Промпт:
packages/analysis-core/src/prompts/biomarker-analysis/diagnostician.md - Tools:
packages/analysis-core/src/tools/diagnostic-graph.tools.ts—DiagnosticGraphExecutor+createDiagnosticGraphTools - Порт из Python:
fhir-services/src/ai/agents/diagnostician_agent.py
Связано
- biomarker-graph — что Диагност читает (через
DiagnosticGraphExecutor) - retriever — следующий шаг, потребляет
DiagnosticPlanи идёт в FHIR пациента проверять, какие из предложенных Диагностом связей реально существуют - biomarker-analysis-pipeline — pipeline, в котором Диагност — шаг 1
- biomarker-actuality-service — концептуальный downstream-потребитель через Retriever
- biomarker-graph-key-loinc — направление, по которому Tier 1/2 уезжают на детерминистический lookup
- llm-stage-cost-measurement — общий cost-framework для всех LLM-стадий (placeholder)
- health-report-vocabulary — терминология стадий
- gemini-doom-loop — mid-decode timeout + nonce-perturbation
- mastra — фреймворк агентского цикла;
diagnostician.mastra.tsиспользует,diagnostician.raw.tsобходит