How to Give Your SOC AI Agent Memory: Closing the Loop with Analyst Feedback

A three-layer memory architecture — entity reputation, rule priors, and an investigation knowledge base — that lets your SOC AI agent get measurably better after every analyst verdict.

An AI triage agent that makes the same mistake on Tuesday that it made on Monday isn’t learning. It’s just guessing faster.

We wrote about building an LLM-powered triage pipeline a few weeks ago. That post covered the request/response shape: alert in, verdict out. What it didn’t cover is the part that actually determines whether the agent is worth keeping around after 90 days — what happens to the analyst’s verdict after the agent gets it wrong.

Most SOC AI deployments treat every alert as a cold start. The model sees the event, maybe a lookback window of raw logs, and produces a verdict with zero awareness that an analyst closed an identical alert from the same IP as a false positive last week. That’s not a model problem. It’s a missing memory layer.

The short version

LayerWhat it storesWhat it’s for
Entity memoryReputation and history per actor ARN / IP / roleInject prior context into triage before the model sees the new alert
Rule memoryTP/FP rates and extracted patterns per detection ruleGive the model a base rate instead of a 50/50 guess
Investigation KBCompressed summaries of closed casesRetrieve similar past investigations for the current one

None of this requires a vector database or a fine-tuned model. It’s three ClickHouse tables, a write path that fires after human review, and a read path that fires before the next triage call.

Why stateless triage plateaus

A triage agent with no memory has exactly one signal: the current alert. It can reason well about that single event, but it can’t answer the questions an experienced L2 analyst answers instantly:

  • “Has this actor done this before, and was it ever confirmed malicious?”
  • “Does this rule normally fire true or false for this kind of source?”
  • “Have we seen this exact pattern closed as a false positive already?”

Without those answers, the model either over-escalates (alert fatigue, the thing you’re trying to avoid) or under-escalates (the thing that gets you breached). Both failure modes get worse over time if nothing is feeding corrections back in. The fix is the same one human SOC teams use: institutional memory, backed by a mandatory feedback loop instead of tribal knowledge that leaves when an analyst does.

Layer 1: entity memory

One row per actor ARN, source IP, or IAM role. ReplacingMergeTree keyed on (entity_type, entity_value) so every update converges to the latest snapshot instead of accumulating duplicate rows:

CREATE TABLE xpernix.soc_entity_memory
(
    entity_type         LowCardinality(String),  -- 'actor_arn' | 'source_ip' | 'role'
    entity_value        String,
    total_alerts        UInt32,
    confirmed_tp_count   UInt32,
    confirmed_fp_count   UInt32,
    reputation_score     Float32,   -- 0.0 clean → 1.0 highly suspicious
    knowledge_summary    String,    -- AI-generated, updated after each closed case
    mitre_tactics_seen   Array(String),
    updated_at           DateTime DEFAULT now(),
    version              UInt64   DEFAULT toUnixTimestamp(now())
)
ENGINE = ReplacingMergeTree(version)
ORDER BY (entity_type, entity_value);

The reputation_score is a weighted blend, not an LLM output — keep the number deterministic so it’s auditable:

tp_rate    = tp_count / (tp_count + fp_count) if (tp_count + fp_count) else 0.5
sev_score  = (critical_count * 1.0 + high_count * 0.6) / total_alerts
reputation = round(0.6 * tp_rate + 0.4 * sev_score, 3)

knowledge_summary is the one field the model does write — a compressed, ≤200-word freetext description of the entity’s behavior, regenerated after every closed case and injected verbatim into the next triage prompt for that actor. That’s the mechanism that lets the agent say “this service account normally does bulk S3 reads at 02:00 UTC as part of a nightly ETL job” instead of flagging it cold every night.

Layer 2: rule memory

Entity memory tells you about the who. Rule memory tells you about the rule — every detection has a real-world TP/FP rate, and the model should know it before forming an opinion:

CREATE TABLE xpernix.soc_rule_memory
(
    rule_name        String,
    total_fired      UInt32,
    tp_count         UInt32,
    fp_count         UInt32,
    avg_confidence   Float32,
    fp_patterns      String,   -- JSON list, AI-extracted
    tp_patterns      String,   -- JSON list, AI-extracted
    last_updated     DateTime DEFAULT now(),
    version          UInt64   DEFAULT toUnixTimestamp(now())
)
ENGINE = ReplacingMergeTree(version)
ORDER BY (rule_name);

A rule that’s 95% false positive should start every triage with the model already skeptical. A rule that’s 90% true positive should start it already leaning to escalate. This is the single highest-leverage table in the whole system — it turns “detection engineering tunes rules” from a quarterly review into a number the AI agent consults on every alert.

The fp_patterns / tp_patterns fields are short, reusable, human-readable conditions extracted by the model itself from closed cases — things like "source IP belongs to known CI/CD range" or "API call outside business hours from unfamiliar region". Cap them at a handful per rule (keep the last 10, deduplicated) so the prompt injection stays small and doesn’t drift into noise.

Layer 3: investigation knowledge base

One row per closed investigation — a MergeTree ordered by (rule_name, closed_at) so “give me the last 3 closed cases for this rule” is a cheap, index-friendly query:

CREATE TABLE xpernix.soc_investigation_kb
(
    id                      UUID DEFAULT generateUUIDv4(),
    alert_id                String,
    rule_name               String,
    entity_value             String,
    verdict                  LowCardinality(String),  -- true_positive | false_positive | inconclusive
    blast_radius              LowCardinality(String),
    mitre_techniques         Array(String),
    case_summary              String,   -- AI-generated, ≤300 words
    distinguishing_signals   String,   -- JSON list — what actually settled the verdict
    closed_at                 DateTime DEFAULT now()
)
ENGINE = MergeTree()
ORDER BY (rule_name, closed_at);

This is deliberately not a vector store. For most SOC teams, “last 3 closed cases for the same rule” beats semantic similarity search on cost and latency, and it’s trivial to reason about when an analyst asks “why did the agent say that?” If you’re running high alert volume across dozens of rule variants, swap the retrieval for embeddings — but don’t reach for that complexity before you’ve proven the plain SQL version isn’t enough.

The write path: consolidation after every closed case

All three tables update from one place: a consolidation step that runs when a case closes, whether that’s a human verdict or an auto-suppress. This is the part teams skip, and it’s the part that actually makes the system learn.

Grafana alert fires
  -> Risk Service / triage agent reads entity + rule memory, forms verdict
  -> analyst reviews, sets human_verdict (true_positive | false_positive | inconclusive)
  -> POST /webhook/case-closed
  -> ConsolidationAgent:
       1. generate updated entity knowledge_summary (Haiku-class model is enough)
       2. extract TP/FP distinguishing patterns for the rule
       3. write entity_memory, rule_memory, investigation_kb
  -> next alert for the same actor/rule reads the updated memory

The consolidation agent should run on a small, cheap model — it’s summarization and pattern extraction, not judgment. Save the larger model for the investigation itself. Two prompts do the work:

ENTITY_SUMMARY_PROMPT:
  Given the history of alerts and investigations for this actor/IP,
  produce a ≤200-word summary a future analyst (or the agent itself)
  can use to quickly assess risk. Focus on behavioral patterns, confirmed
  techniques, and whether past alerts were real or noise, and why.

PATTERN_EXTRACTION_PROMPT:
  Given a closed investigation, return JSON with fp_patterns and
  tp_patterns — concrete, reusable conditions that distinguished this
  case as true or false positive. Max 3 patterns per list.

Both prompts return short, structured text specifically because it gets re-injected into future prompts. A knowledge summary that grows unbounded eventually crowds out the actual alert context — cap it, regenerate it (not append to it) on every update, and treat prompt budget as a resource you’re actively managing.

The read path: context before judgment, not after

Memory only works if it’s assembled before the model forms an opinion, not fetched afterward to justify one. Build a single context object per triage call:

context = {
    "actor_history": {
        "reputation_score": 0.82,
        "confirmed_tp_count": 4,
        "confirmed_fp_count": 1,
        "knowledge_summary": "Service account tied to nightly ETL job...",
    },
    "rule_history": {
        "tp_rate": 0.12,
        "fp_patterns": ["source IP in known CI/CD range", "..."],
    },
    "similar_past_cases": [...],  # last 3 closed cases for this rule
}

Inject it into the system or user prompt ahead of the raw alert payload. The model should see “this rule is normally 88% noise, and this specific actor has a clean track record” before it sees the alert details — same order a good human analyst works in.

One thing worth calling out explicitly: everything in this context object except the numeric fields is model-generated text stored and replayed back into a future prompt. That’s a second-order prompt injection surface — if knowledge_summary or case_summary ever gets contaminated by an attacker-controlled log field flowing through the investigation, it re-enters every future triage for that entity. Apply the same untrusted-input handling from defending AI agents against prompt injection to what you write into memory, not just what you read out of alerts.

What this buys you

Before memoryAfter memory
Every alert triaged coldPrior TP/FP rate and actor history injected up front
Analyst re-explains the same FP pattern every weekPattern extracted once, applied automatically going forward
No way to answer “why did the AI escalate this”case_summary and distinguishing_signals are queryable
Model quality is static after deploymentModel output quality tracks the volume of reviewed cases

The tradeoff is operational, not architectural: this only works if human verdicts are actually being recorded. An agent with a memory system and a 20% review rate is still mostly guessing — the write path is only as good as the discipline behind closing cases with a real verdict instead of leaving them inconclusive.

Final thought

The interesting failure mode with LLM-powered SOC tooling isn’t hallucination — it’s amnesia. A model that reasons well about one alert in isolation and forgets everything the moment the response streams back isn’t an analyst, it’s a very well-read intern who never gets promoted. Three ClickHouse tables and a consolidation step after every closed case is a small amount of infrastructure for a system that compounds instead of plateauing.

If you’re building this into your own detection stack and want a second pair of eyes on the schema or the consolidation prompts, book a discovery call.