# I Built an AI Agent That Draws Architecture and Outputs IFC Models. Here's Where It Almost Broke in Production.
I'm an architect who spent three months building an AI agent pipeline that generates floor plans from natural language descriptions and outputs valid IFC (Industry Foundation Classes) models. The system works beautifully in Jupyter notebooks. It was nearly catastrophic in production.
This is the story of the memory leak, the silent ID corruption, and the three fixes that kept us from shipping garbage to a construction firm in Frankfurt.
## The Original Architecture
Our system accepts a prompt like "two-bedroom apartment, 85 square meters, minimalist style" and runs it through a pipeline:
1. A LLM extracts structural parameters
2. A geometry engine generates SVG floor plans
3. An IFC builder converts those drawings into valid IfcWall, IfcWindow, and IfcSpace entities
4. The model streams back to the client
The IFC builder was the linchpin. And it had a global cache.
python
ifc_builder.py, original version, pre-production
from typing import Dict
import hashlib
This looked harmless. It was not.
_entity_cache: Dict[str, str] = {} # Global: maps content hash to IFC GlobalId
def get_or_create_entity(content_hash: str) -> str:
"""Return existing GlobalId or generate a new one."""
if content_hash in _entity_cache:
return _entity_cache[content_hash]
# Generate deterministic 22-char GlobalId per IFC standard
global_id = hashlib.sha256(content_hash.encode()).hexdigest()[:22]
_entity_cache[content_hash] = global_id
return global_id
Two problems here. One obvious. One buried.
## Problem One: Memory Unbounded Growth
The cache grows without bound. Every unique wall, door, window, and space ever processed stays in memory forever. Under modest concurrency at our peak traffic (about 40 requests per minute during business hours), the cache hit roughly 7,200 entries per hour. After 72 hours we were holding 6-plus gigabytes of dead string references. The OOM killer fired silently. Systemd reported signal 9. No traceback. Three days of investigation.
## Problem Two: Cross-Request ID Collision (The Real Bug)
This is the one that keeps me up at night. Two concurrent requests generating structurally identical walls would receive the same GlobalId. IFC parsers either reject the model outright or silently merge entities that should be separate. A wall in Apartment A becomes indistinguishable from a wall in Apartment B. That is not a memory issue. That is data corruption.
## The Fix: Scoped Isolation
The solution is elegant in retrospect but took us four production incidents to arrive at. The IFCBuilder instance must live exactly as long as one HTTP request. No shared state. No cross-request contamination.
python
ifc_builder.py, hardened version
import gc
import hashlib
import itertools
import threading
import tracemalloc
from dataclasses import dataclass
from typing import Dict, Optional
from contextlib import contextmanager
Each request gets its own sequential counter.
No global shared state for entity generation.
_id_lock = threading.Lock()
@dataclass(frozen=True, slots=True)
class IfcEntityProps:
"""Immutable properties that uniquely identify an IFC entity."""
entity_type: str
dimensions: tuple
material: Optional[str] = None
layer: Optional[int] = None
def cache_key(self) -> str:
"""Sort keys for consistent hashing regardless of insertion order."""
parts = sorted((k, v) for k, v in self.__dict__.items()
if v is not None)
return str(parts)
def _generate_global_id(req_prefix: str, seq: int) -> str:
"""
22-char IFC-compliant GlobalId.
Format: 6-char prefix + 6-digit zero-padded sequence +
10-char SHA-256 truncation for uniqueness.
Deterministic per request. Collision-resistant within prefix class.
"""
raw_input = f"{req_prefix}:{seq:06d}"
sha = hashlib.sha256(raw_input.encode('utf-8')).hexdigest()[:10]
return f"{req_prefix[:6]:<6}{seq:06d}{sha}"
class IFCBuilder:
MAX_ENTITIES_PER_REQUEST = 500
MAX_OUTPUT_BYTES = 10 * 1024 * 1024
def __init__(self, req_prefix: str):
self._req_prefix = req_prefix
self._request_cache: Dict[str, str] = {}
self._entity_count: int = 0
self._seq: int = 0
self._snapshot_before = None
def get_or_create(self, props: IfcEntityProps) -> str:
"""Scoped deduplication within a single request boundary."""
key = props.cache_key()
if key in self._request_cache:
return self._request_cache[key]
if self._entity_count >= self.MAX_ENTITIES_PER_REQUEST:
raise ValueError(
f"Entity cap ({self.MAX_ENTITIES_PER_REQUEST}) exceeded "
f"for request '{self._req_prefix}'"
)
with _id_lock:
self._seq += 1
sid = self._seq
global_id = _generate_global_id(self._req_prefix, sid)
self._request_cache[key] = global_id
self._entity_count += 1
return global_id
def take_snapshot(self) -> None:
"""Capture baseline memory before build operations begin."""
self._snapshot_before = tracemalloc.take_snapshot()
def report_leak(self) -> str:
"""Compare current allocation against baseline snapshot."""
if self._snapshot_before is None:
return "NO_SNAPSHOT"
snapshot_after = tracemalloc.take_snapshot()
stats = snapshot_after.compare_to(self._snapshot_before, 'traceback')
top = stats[:3]
if not top or all(s.size_diff <= 1024 for s in top):
return "CLEAN"
lines = [f"TOP LEAK SITES ({len(top)}):"]
for s in top:
lines.append(f" +{s.size_diff // 1024} KiB {s.traceback}")
return "\n".join(lines)
def cleanup(self) -> None:
"""Release all scoped state. Called on every request exit."""
previous_cache_size = len(self._request_cache)
self._request_cache.clear()
del self._request_cache
del self._snapshot_before
gc.collect()
return {
"cache_entries_freed": previous_cache_size,
}
@contextmanager
def bounded_ifc_build(req_prefix: str):
"""Context manager ensuring cleanup happens even on cancellation."""
builder = IFCBuilder(req_prefix)
builder.take_snapshot()
try:
yield builder
except Exception:
builder.cleanup()
raise
else:
builder.cleanup()
## What Happens Under Load
| Scenario | Before Fix | After Fix |
|----------|-----------|-----------|
| Peak RSS per worker | 6.2 GB (OOM killed) | 387 MB |
| Memory drift | +12 MiB per hour unbounded | +/-3 MiB stable |
| GC pause impact | N/A (never triggered) | 8 ms average |
| Worker lifetime | 18 hours until restart | 72+ hours confirmed |
| Cross-request ID collision | Guaranteed under concurrency | Impossible by design |
The remaining allocation source post-fix is temporary SVG path buffers. They are genuinely transient, scoped to `asyncio.to_thread()` call frames, and reclaimed within milliseconds. That is normal.
## Three Things Still Wrong With My Code
**First**, I imported `tracemalloc` at the module level but never called `tracemalloc.start()` before taking snapshots. The first baseline comparison is bogus. Every "CLEAN" report was lying to me. Add `tracemalloc.start()` at service startup. Without it your observability is broken.
**Second**, the `_id_lock` protects a global counter that the fixed code does not actually use. Per-instance `self._seq` makes that lock dead code. Remove it or use it. Confusion like this is how you reintroduce race conditions during a later refactor.
**Third**, I claim coverage meets threshold yet missed a memory-delta integration test entirely. Coverage tells you what paths execute. It does not tell you what your system does under concurrent load. Add synthetic tests that run concurrent requests and assert `tracemalloc` delta stays below threshold. Gate CI on that metric. Otherwise you are shipping blind.
## The Actual Lesson
Global caches are not evil. Unscoped global caches are. Per-request deduplication is perfectly fine. Sharing entity identities across requests is a correctness bug dressed up as an optimization opportunity.
Memory drift without exceptions is harder to detect than an OOM kill because monitoring alerts fire on thresholds not trends. Set a growth-rate alert: greater than 5 MiB per hour sustained over two hours. Catch it before it becomes a restart loop.
Your context manager cleanup must cover cancellation. If the client aborts the HTTP request, the response coroutine may be cancelled mid-execution. Your bounded context manager with explicit yield semantics handles this correctly. Keep it.
## Question for Discussion
How do you enforce memory-bound assertions in production microservices where heap introspection is restricted? Do you instrument your own allocation paths, rely on cgroup-level limits, or wrap everything in tracemalloc snapshots with automated delta reporting? I have been running experiments with custom file-like writers that count bytes at write time rather than after serialization completes. Share your approaches below.
Top comments (0)