CalcuOnline
HomeTech Tutorials / ReviewsHealth & MedicalFine-Tuning a Llama 3 Model for Automated Clinical Documentation and EHR Integration
Fine-Tuning a Llama 3 Model for Automated Clinical Documentation and EHR Integration

Fine-Tuning a Llama 3 Model for Automated Clinical Documentation and EHR Integration

Health & Medical 5.0 Updated 24 August 2026

Why fine-tune instead of prompting a general model

Base instruction-tuned Llama 3 models can draft clinical notes with prompting alone, but they drift on institution-specific formatting (your health system's exact SOAP template, abbreviation conventions, section ordering) and on domain vocabulary density. Fine-tuning on a corpus of (transcript, note) pairs teaches the model your house style with far fewer output-formatting errors than prompting achieves, without the cost of full-parameter training. This tutorial uses QLoRA (4-bit quantized base weights + trainable low-rank adapters) on Llama 3 8B, which fits on a single 24GB GPU, and finishes with a FHIR DocumentReference integration so the model's output actually lands in the EHR.

All training data referenced here must be de-identified per HIPAA Safe Harbor (§164.514(b)) or run inside a BAA-covered compute environment before any real patient data touches it.

1. Technical architecture

Training path (offline)
┌───────────────────┐   ┌────────────────────┐   ┌───────────────────────┐
│ De-identified note   │──▶│ Dataset builder        │──▶│ QLoRA fine-tuning       │
│ pairs (JSONL)          │   │ (prompt/response fmt)  │   │ job (single A10G/A100)  │
└───────────────────┘   └────────────────────┘   └───────────┬───────────┘
                                                                  ▼
                                                    ┌──────────────────────┐
                                                    │ Merged adapter weights │
                                                    │ pushed to model registry│
                                                    └──────────┬───────────┘
Inference path (online)                                        │
┌───────────────────┐   ┌────────────────────┐   ┌────────────▼───────────┐
│ Clinician dictation  │──▶│ vLLM inference server  │◀──│ Load fine-tuned adapter │
│ or transcript          │   │ (OpenAI-compatible API)│   └────────────────────────┘
└───────────────────┘   └─────────┬──────────┘
                                     ▼
                          ┌────────────────────┐
                          │ Structured note        │
                          │ parser (JSON schema)    │
                          └─────────┬──────────┘
                                     ▼
                          ┌────────────────────┐
                          │ FHIR client → EHR       │
                          │ DocumentReference        │
                          │ (draft status)           │
                          └────────────────────┘

2. Complete code implementation

2.1 Dataset preparation

# data/build_dataset.py
import json
from pathlib import Path
from datasets import Dataset

SYSTEM_PROMPT = (
    "You are a clinical scribe. Convert the encounter transcript into a SOAP note "
    "using this exact section order: Subjective, Objective, Assessment, Plan. "
    "Use only information stated in the transcript."
)


def build_prompt(transcript: str) -> str:
    return (
        f"<|start_header_id|>system<|end_header_id|>\n{SYSTEM_PROMPT}<|eot_id|>"
        f"<|start_header_id|>user<|end_header_id|>\n{transcript}<|eot_id|>"
        f"<|start_header_id|>assistant<|end_header_id|>\n"
    )


def load_pairs(jsonl_path: str) -> Dataset:
    """jsonl_path rows look like: {"transcript": "...", "note": "..."}"""
    rows = []
    with open(jsonl_path, "r", encoding="utf-8") as fh:
        for line in fh:
            if not line.strip():
                continue
            record = json.loads(line)
            if not record.get("transcript") or not record.get("note"):
                continue  # skip incomplete rows rather than crash the whole build
            rows.append({
                "text": build_prompt(record["transcript"]) + record["note"] + "<|eot_id|>"
            })
    if not rows:
        raise ValueError(f"No valid transcript/note pairs found in {jsonl_path}")
    return Dataset.from_list(rows)


if __name__ == "__main__":
    ds = load_pairs("data/train_pairs.jsonl")
    ds.save_to_disk("data/train_dataset")
    print(f"Built dataset with {len(ds)} examples")

2.2 QLoRA fine-tuning script

# train/finetune_qlora.py
import torch
from datasets import load_from_disk
from transformers import (
    AutoModelForCausalLM,
    AutoTokenizer,
    BitsAndBytesConfig,
    TrainingArguments,
)
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
from trl import SFTTrainer

BASE_MODEL = "meta-llama/Meta-Llama-3-8B-Instruct"
OUTPUT_DIR = "checkpoints/llama3-clinical-scribe"

bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_compute_dtype=torch.bfloat16,
    bnb_4bit_use_double_quant=True,
)

tokenizer = AutoTokenizer.from_pretrained(BASE_MODEL)
tokenizer.pad_token = tokenizer.eos_token

model = AutoModelForCausalLM.from_pretrained(
    BASE_MODEL,
    quantization_config=bnb_config,
    device_map="auto",
)
model = prepare_model_for_kbit_training(model)

lora_config = LoraConfig(
    r=16,
    lora_alpha=32,
    target_modules=["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"],
    lora_dropout=0.05,
    bias="none",
    task_type="CAUSAL_LM",
)
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()  # sanity check: expect ~0.5-1% of total params

train_dataset = load_from_disk("data/train_dataset")

training_args = TrainingArguments(
    output_dir=OUTPUT_DIR,
    per_device_train_batch_size=2,
    gradient_accumulation_steps=8,   # effective batch size 16
    num_train_epochs=3,
    learning_rate=2e-4,
    bf16=True,
    logging_steps=10,
    save_strategy="epoch",
    save_total_limit=2,
    warmup_ratio=0.03,
    lr_scheduler_type="cosine",
    report_to="none",
)

trainer = SFTTrainer(
    model=model,
    args=training_args,
    train_dataset=train_dataset,
    dataset_text_field="text",
    max_seq_length=4096,
)

if __name__ == "__main__":
    trainer.train()
    trainer.model.save_pretrained(f"{OUTPUT_DIR}/final_adapter")
    tokenizer.save_pretrained(f"{OUTPUT_DIR}/final_adapter")
    print("Training complete. Adapter saved to", f"{OUTPUT_DIR}/final_adapter")

2.3 Merge adapter and serve with vLLM

# train/merge_adapter.py
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import PeftModel

BASE_MODEL = "meta-llama/Meta-Llama-3-8B-Instruct"
ADAPTER_DIR = "checkpoints/llama3-clinical-scribe/final_adapter"
MERGED_DIR = "checkpoints/llama3-clinical-scribe/merged"

base = AutoModelForCausalLM.from_pretrained(BASE_MODEL, torch_dtype="bfloat16")
model = PeftModel.from_pretrained(base, ADAPTER_DIR)
model = model.merge_and_unload()  # bakes LoRA deltas into the base weights
model.save_pretrained(MERGED_DIR, safe_serialization=True)

tokenizer = AutoTokenizer.from_pretrained(BASE_MODEL)
tokenizer.save_pretrained(MERGED_DIR)
print("Merged model written to", MERGED_DIR)
# Serve the merged model with an OpenAI-compatible API
pip install vllm --break-system-packages
python -m vllm.entrypoints.openai.api_server \
  --model checkpoints/llama3-clinical-scribe/merged \
  --served-model-name clinical-scribe-llama3 \
  --port 8000 \
  --max-model-len 4096 \
  --gpu-memory-utilization 0.90

2.4 Structured parsing and FHIR integration

# integration/fhir_push.py
import json
import base64
import requests
from requests.exceptions import Timeout, HTTPError
from openai import OpenAI  # vLLM's OpenAI-compatible client

client = OpenAI(base_url="http://localhost:8000/v1", api_key="not-needed")


def generate_note(transcript: str) -> str:
    resp = client.chat.completions.create(
        model="clinical-scribe-llama3",
        messages=[
            {"role": "system", "content": "You are a clinical scribe producing a SOAP note."},
            {"role": "user", "content": transcript},
        ],
        temperature=0.2,
        max_tokens=800,
    )
    return resp.choices[0].message.content


def push_to_ehr(fhir_base_url: str, access_token: str, patient_id: str, encounter_id: str,
                 note_text: str, timeout_seconds: int = 10) -> dict:
    """Creates a draft FHIR DocumentReference. Status is deliberately 'preliminary'
    so it requires clinician sign-off inside the EHR before becoming final."""
    encoded_note = base64.b64encode(note_text.encode("utf-8")).decode("ascii")
    resource = {
        "resourceType": "DocumentReference",
        "status": "preliminary",
        "type": {"coding": [{"system": "http://loinc.org", "code": "11506-3", "display": "Progress note"}]},
        "subject": {"reference": f"Patient/{patient_id}"},
        "context": {"encounter": [{"reference": f"Encounter/{encounter_id}"}]},
        "content": [{
            "attachment": {"contentType": "text/plain", "data": encoded_note}
        }],
    }

    try:
        resp = requests.post(
            f"{fhir_base_url}/DocumentReference",
            headers={
                "Authorization": f"Bearer {access_token}",
                "Content-Type": "application/fhir+json",
            },
            data=json.dumps(resource),
            timeout=timeout_seconds,
        )
        resp.raise_for_status()
    except Timeout:
        raise RuntimeError("FHIR server timed out — encounter note NOT saved. Retry or queue for later.")
    except HTTPError as exc:
        status = exc.response.status_code
        if status == 401:
            raise RuntimeError("EHR access token expired — refresh OAuth2 token and retry.") from exc
        if status == 422:
            raise RuntimeError(f"FHIR server rejected resource as invalid: {exc.response.text}") from exc
        raise

    return resp.json()

3. Step-by-step configuration guide

  1. Provision GPU compute. A single NVIDIA A10G (24GB) or better handles QLoRA fine-tuning of the 8B model; use an A100 40GB+ if you plan longer context windows or larger batch sizes.
  2. Install dependencies:
    pip install torch transformers peft trl bitsandbytes accelerate datasets --break-system-packages
    huggingface-cli login   # required to pull the gated Llama 3 weights
    
  3. Prepare training data as data/train_pairs.jsonl, one {"transcript": ..., "note": ...} object per line, then run python data/build_dataset.py.
  4. Run training: python train/finetune_qlora.py — monitor logging_steps output; loss should trend down steadily over the 3 epochs.
  5. Merge and export: python train/merge_adapter.py.
  6. Start the inference server: run the vllm.entrypoints.openai.api_server command from §2.3.
  7. Configure EHR OAuth2 credentials. Register a SMART on FHIR backend service app with your EHR vendor (Epic, Cerner/Oracle Health, etc.), store the client ID/private key in a secrets manager, and implement the JWT-bearer token exchange to obtain access_token before calling push_to_ehr.
  8. Smoke test: call generate_note() with a sample transcript, inspect the output, then call push_to_ehr() against your EHR sandbox environment — never point this at a production EHR until the note quality has been clinically validated.

4. Error handling and edge cases

Related Reviews

How to Build a HIPAA-Compliant AI Medical Scribe App Using AWS Bedrock and Python
How to Build a HIPAA-Compliant AI Medical Scribe App Using AWS Bedrock and Python
5.0
A reference architecture and working Python implementation for an ambient AI scribe that transcribes clinician-patient visits and drafts SOAP notes inside…
Read Review → Health & Medical
Deploying a Secure DICOM Medical Image Classification API on Google Cloud Vertex AI
Deploying a Secure DICOM Medical Image Classification API on Google Cloud Vertex AI
5.0
Build and deploy a DICOM-aware image classification service on Vertex AI, covering de-identification, containerized preprocessing, model serving, and IAM-…
Read Review → Health & Medical