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
| Layer | What it stores | What it’s for |
|---|---|---|
| Entity memory | Reputation and history per actor ARN / IP / role | Inject prior context into triage before the model sees the new alert |
| Rule memory | TP/FP rates and extracted patterns per detection rule | Give the model a base rate instead of a 50/50 guess |
| Investigation KB | Compressed summaries of closed cases | Retrieve 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 memory | After memory |
|---|---|
| Every alert triaged cold | Prior TP/FP rate and actor history injected up front |
| Analyst re-explains the same FP pattern every week | Pattern 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 deployment | Model 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.