Tradeoffs¶
Each row names what we chose, what we gave up, and why.
1. Infrastructure¶
| Chose | Gave up | Why |
|---|---|---|
| Bedrock global cross-region inference | A guarantee that conversations are processed only in Korea | There is no other way to call this model from Seoul. Logs, quota and billing stay in Seoul |
| Plain HTTP inside the VPC | Encryption between services | Service Connect TLS needs a private CA, which is expensive. Security groups and private subnets restrict who can connect |
| VPC endpoints for AWS services, NAT for the rest | Full isolation with no NAT | Agent-auth signing keys and prod's real external systems are on the internet |
| Agent identity resolved only at the frontend | A second check in the backend | The backend is reachable only from the frontend's security group, so it trusts X-Agent-Id. Customer tokens are still checked by the backend (HMAC lookup) |
| Checkpoints in RDS | TTL-based expiry and key-value scalability (DynamoDB) | The encrypting serializer plugs in through a public argument, and there is one store instead of two. Old checkpoints are to be removed by a daily cleanup job instead of TTL (designed, not built) |
| One mock service for four systems | Four services that look like production | Lower develop cost and one deployment |
| Real Bedrock in develop, mock only locally | A free, fully repeatable develop | Develop exercises the real model, the IAM policy and the bedrock-runtime VPC endpoint. Local runs and CI stay offline and deterministic |
| Develop interface endpoints in one AZ | Develop's AWS API access if 2a fails; cross-AZ transfer cost | Halves the largest item in the develop bill. Prod has both AZs |
| One RDS instance, three schemas | Independent scaling and failure isolation per store | Cheapest option; the data sizes in scope are small |
| Frontend relays all backend calls | A little latency and one more hop for SSE | The backend is never public; one place handles auth |
One frontend service with two apps (/s, /agent) |
Deploying the agent console on its own | The brief asks for two services. Separate layouts and routes still keep them apart for users |
| Fixed AES key for checkpoints, no rotation | Key rotation | Simple, no extra calls. Checkpoints are meant to expire after 30 days, so rotation can be added later with a reader that accepts the old key for 30 days |
| Alembic migrations run by the backend at startup | A separate migration task in the deploy | No extra step to order in the deploy; an advisory lock serialises replicas. A migration runs while the previous release still serves, so it must stay backward compatible |
2. LLM: Claude Sonnet 4.6 on Bedrock¶
First choice was OpenAI GPT-5.6 Luna (global.openai.gpt-5.6-luna) on Bedrock. The Marketplace subscription
was ACTIVE and the terms were accepted, but every call failed with
AccessDeniedException: not available for this account. It was not IAM (the caller had AdministratorAccess,
and the policy simulator said allowed). The account's tokens-per-minute quota for that model was 0
(quota L-D983F7C0; the AWS default is 20 million). A quota increase request was not granted.
We then looked for models with both quota and accepted terms in this account (Seoul, global inference):
| Model | Tokens per minute quota | Terms |
|---|---|---|
| Claude Sonnet 4.6 | 6 million | Accepted |
| Claude Haiku 4.5 | 5 million | Accepted |
| Amazon Nova 2 Lite | 8 million | Accepted |
Both Claude models were tested with the three with_structured_output methods. Input: the Korean sentence used
by seed customer A ("I'm a 35-year-old software engineer living in Seoul. I'm worried my new Galaxy phone will
break."). Schema: the needs-extraction fields.
| Method | Sonnet 4.6 | Haiku 4.5 |
|---|---|---|
function_calling (default) |
Works, 1.9 s | Works, 1.3 s |
json_schema |
Works, 5.3 s | Works, 4.2 s |
prompt_prefill |
Rejected: the model does not accept a prefilled response | Works, 0.9 s |
Both extracted the age range (30s), occupation, country (KR) and objective (PROTECT_DEVICE) correctly.
| Chose | Gave up | Why |
|---|---|---|
| Sonnet 4.6 for every node | Lower cost and latency of Haiku 4.5 | Recommendation reasons and application summaries are visible in the demo; writing quality matters. Extraction nodes can move to Haiku through a per-node setting later |
function_calling |
json_schema strictness |
Default, works on both models, and was 2–3× faster in one-off measurements (to be re-measured) |
| Temperature 0 | Varied wording | Stable extraction output |
Found in the test and fed into the design: Sonnet put objective values into missing_fields instead of
field names, because the schema description was ambiguous. The field description now says explicitly that it
holds names of fields that are still unknown.
3. Checkpoint store: DynamoDB vs PostgreSQL¶
First choice was DynamoDB (DynamoDBSaver from langgraph-checkpoint-aws 1.2.3). We tested application-level
encryption with moto (mocked DynamoDB and S3): stored a message containing a phone number, then read the raw
table and bucket values directly.
| Setup | Reads back | Plain text visible in storage | Stored in |
|---|---|---|---|
| No encryption | Yes | Yes | DynamoDB |
| Encryption | Yes | No | DynamoDB |
| Large checkpoint, library compression + encryption | Yes | No | Offloaded to S3: ciphertext does not compress |
| Large checkpoint, compress-then-encrypt serializer | Yes | No | DynamoDB |
Reading with a different key fails, as it should. Three findings:
DynamoDBSaverdoes not accept a serializer in its constructor. It captures the default serializer in an internal object (saver.serializer.serde), so settingsaver.serdehas no effect. Making it work needs a subclass that patches both places, a pinned library version, and a regression test.- Library compression happens after serialization. If the serializer already encrypts, the library compresses ciphertext, which does not shrink, and large checkpoints get offloaded to S3 more often. So the serializer must compress first and encrypt second.
EncryptedSerializerjoins type names with+. An inner serializer whose type name contains+breaks on read. The gzip layer therefore marks its type with agz_prefix (gz_msgpack+aes).
Finding 1 decided it: it works, but only by touching non-public internals. PostgresSaver
(langgraph-checkpoint-postgres 3.1.2) takes serde as a constructor argument. We moved checkpoints to RDS and kept
the compress-then-encrypt serializer from finding 2.
The test conversation repeated the same text and compresses far better than real chat. The real compression ratio still needs measuring.
| Chose | Gave up | Why |
|---|---|---|
PostgresSaver on RDS |
DynamoDB TTL and scaling | Supported serializer injection; one data store |
| Compress, then AES | Library-level compression | Compression after encryption does nothing |
4. Application design¶
| Chose | Gave up | Why |
|---|---|---|
| Eligibility, ranking and pricing in code | Letting the LLM reason about fit | Every exclusion and price must be traceable (failure_reason_code, rating_inputs). The LLM explains, grounded in catalog rationale text |
| IDs in state, values in the DB | Self-contained checkpoints | One source of truth; no personal values duplicated into state |
| Identity routing on results, not counters | Flexible retry counts in the graph | The graph shape encodes the policy (OTP fail → document → agent); the attempt count lives in the DB. The only counters in state are the two 3-round loop guards |
| Node writes and checkpoint save in separate transactions | Atomicity | LangGraph saves checkpoints on its own connection. Idempotent writes (deterministic IDs, upserts, idempotency key) make re-runs safe |
Handoff split into human_handoff (writes) and await_agent (interrupt) |
One node | An interrupted node re-runs from the top on resume. Doing the writes in a node before the pause saves them once, and the agent sees the handoff state while the session waits |
| 3-round cap on the needs and answers loops | Asking until the customer gets it right | A customer (or a fixed mock answer) that never fills a field would loop forever. After 3 rounds an agent takes over |
Input returns 202 and the graph runs in a background task |
A synchronous response with the next question | LLM calls take seconds; the UI already listens on SSE, so the request returns at once and the result streams in |
SSE fan-out over Postgres LISTEN/NOTIFY |
Redis pub/sub; a hosted hub (Centrifugo, AppSync Events) | No new infrastructure: the RDS instance is already there. Payloads are capped at 8000 bytes, so a larger event closes the session's streams and the browser refetches. Delivery is at most once, covered by the refetch every reconnect already does |
| Per-session lock in the backend process | A Postgres advisory lock | Simplest correct lock for one replica. Prod's two tasks need it moved to Postgres first |
| Keep ineligible recommendations as rows | Smaller tables | The agent can see why a product is missing |
| Needs assessments versioned, never edited | Simpler updates | Each recommendation keeps the exact assessment it was based on |
| One mock with fault injection | Testing against real partners | Deterministic tests and a repeatable demo of retries and handoff |