


























“Most Agent projects die in the unmapped wilderness between PoC and production.”
I wrote that line while reading through the Relay project documentation. Relay is an open-source AI Agent system for job searching — not a demo built on three lines of LangChain plus GPT-4, but a project with complete architectural documentation, 172 engineering tasks, a hybrid tech stack, and explicit counterexamples for every major design decision.
It is not fully running yet. The Agent layer code is still being written. That is exactly why I think this article is worth writing: this is a system that has thought very deeply at the design level, and those deep thoughts — regardless of where this project ultimately lands — are valuable references for everyone doing Agent engineering.
This article is not a product overview. It is an architectural breakdown.
Before discussing architecture, I want to answer a more fundamental question: why is job searching a domain for Agents rather than just AI tools?
Job searching is a multi-stage, multi-tool, cognitively intensive workflow:
Resume prep → Job search → Resume tailoring → Form fill → Application tracking
↑ ↓
└─────────── Interview prep ← Interview invite ←───────────┘
Every node involves enormous amounts of low-value mechanical work — searching, copying, pasting, formatting, filling forms. At the same time, every node’s high-value judgments — is this role right for me? how should I frame this experience? how should I practice this interview question? — are highly personal and deeply context-dependent.
That is exactly where an Agent system should intervene: automate mechanical labor, assist high-value judgment, and make irreversible actions transparent.
Relay’s north star is: “quality over quantity — one precise shot beats a hundred blind sprays.”
This positioning alone determines that the architecture cannot be “one-click mass apply.” It must be “every application goes through user review.”
Relay’s architecture has five layers, from the bottom up:
+----------------------------------------------------------+
| Layer 5: UI |
| Next.js 16 web console + Manifest V3 browser extension |
+----------------------------------------------------------+
| Layer 4: API + Orchestration |
| Hono/Bun TypeScript API + Redis Event Bus |
+----------------------------------------------------------+
| Layer 3: Agent Execution |
| Python FastAPI + LangGraph (5 domain agents) |
+----------------------------------------------------------+
| Layer 2: Shared Services |
| Auth, Notification, Audit, LLM Router |
+----------------------------------------------------------+
| Layer 1: Data + External Integrations |
| PostgreSQL + pgvector, Redis, MinIO, OpenRouter |
+----------------------------------------------------------+
The most central design decision is the hybrid backend: TypeScript (Hono + Bun) for the API layer, Python (FastAPI + LangGraph) for the Agent layer, connected via HTTP + Redis + a shared PostgreSQL instance.
This is not a compromise. It is an intentional division of labor:
The two layers do not share a process, only data. The TypeScript API calls the Python FastAPI via HTTP; the Python Agent writes to PG, and the TypeScript API reads from it. No RPC framework, no gRPC. The simplest decoupling approach is often the most reliable.
This is the most worthwhile design decision to dig into.
Many teams’ first instinct when building an Agent system is “one Agent does everything.” The reasoning is straightforward: fewer services means fewer failure modes; context stays complete inside one Agent; no inter-agent communication needed.
But this intuition runs into fundamental problems as the system grows:
Coordination cost scales as O(N squared). When one Agent must simultaneously handle “parse a PDF resume,” “match jobs,” “generate interview questions,” and “scrape market trends,” the prompt grows unboundedly, the model must switch between radically different tasks, errors cannot be isolated, and debugging becomes nearly impossible.
Model tier requirements conflict. Parsing a PDF needs a fast, cheap model. Deeply evaluating an interview performance needs a model with strong reasoning. Batch ETL needs the lowest possible cost. A single Agent either uses the most expensive model for everything, or introduces extremely complex model-switching logic.
Prompt evolution cadences differ. The “resume optimization” prompt might update weekly. The “trend report” prompt might update quarterly. The “interview question generation” prompt needs continuous iteration based on crowdsourced data. Mixed together, any update to one can affect the others.
Data flywheels cannot grow independently. The value of the interview question database needs to aggregate from all users’ interview records — that is independent business logic that should not be mixed with resume parsing.
Relay splits responsibility into 5 single-purpose agents:
| Agent | Core Responsibility | Trigger | Primary Model |
|---|---|---|---|
| ResumeAgent | Parse / optimize / tailor resume | User upload / click | GLM-4.7 (optimize) + V4Flash (parse) |
| JobMatchAgent | Scrape / parse / match jobs | Cron + events | V4Flash + Embeddings |
| InterviewAgent | Generate questions / evaluate answers | User-initiated conversation | V4Pro (evaluate) + GLM-4.7 (generate) |
| AppPrepAgent | Prepare application package | Coordinator call | GLM-4.7 + V4Flash |
| TrendAgent | ETL / skill extraction / reports | Daily cron | V4Flash + DuckDB |
Every split maps to at least one of the four dimensions above: different triggers, different model tiers, different data flywheels, different prompt evolution cadences. Not splitting for its own sake.
Above the five domain agents sits a Coordinator — “Ask Vantage” — the entry point for user conversations.
The Coordinator’s core job is intent recognition + tool routing, implemented as a LangGraph create_react_agent with 12 registered tools:
tools = [
# Propose plan (HITL)
propose_plan,
# Call domain agents
tailor_resume, find_jobs, start_mock_interview, draft_cover_letter,
# Memory recall
recall_user_memory, recall_past_applications, recall_weak_points,
# Admin operations
list_my_applications, build_resume_from_scratch, trends_today,
# Narrate
narrate,
]
One detail worth highlighting: fast-path and slow-path separation.
For simple intents (“show my applications,” “what are today’s trends”), a regex + V4Flash classifier runs first. If confidence is 95% or higher, the full ReAct reasoning loop is bypassed and the request is routed directly. This drops latency for the vast majority of simple requests by an order of magnitude while reserving expensive reasoning capacity for genuinely complex problems.
Relay repeats one design principle throughout its documentation: the user must personally click Submit before any application is created.
This is not a product UI choice. It is an architectural commitment.
Submitting an application is irreversible. Sending an email is irreversible. Deleting data is irreversible.
An Agent system without HITL for these actions is “a proxy with no undo key.” Users not trusting it — no matter how intelligent it is — is completely rational.
Relay splits tool permission into four tiers:
class Permission(Enum):
AUTO = "auto" # Execute silently, no notification
NOTIFY = "notify" # Execute then send WebSocket notification
APPROVE = "approve" # Pause and wait for user confirmation
BLOCK = "block" # Not registered, never executed
APPROVE is the critical tier. submit_form, send_email, delete_*, and similar operations all live here.
LangGraph provides the interrupt() primitive to implement HITL checkpoints:
from langgraph.types import interrupt, Command
@tool
def submit_form(job_url: str, fields: dict) -> str:
# Pause before executing, wait for user confirmation
decision = interrupt({
"action": "submit_form",
"job_url": job_url,
"fields": fields,
"message": "Agent wants to submit to this position. Review the form and approve.",
})
if decision.get("type") == "approve":
# User may modify fields before approving
return do_submit(job_url, decision.get("fields", fields))
return "User cancelled this application."
When interrupt() is called, LangGraph persists the current graph state to PostgreSQL via PostgresSaver checkpointer and then pauses execution. After the user confirms in the frontend, the API layer sends Command(resume={"type": "approve", ...}) back to the graph, and execution resumes precisely from the pause point:
# After user approves
graph.invoke(
Command(resume={"type": "approve", "fields": modified_fields}),
config={"configurable": {"thread_id": session_id}},
)
The most important technical detail here is the checkpointer. Without a checkpointer, interrupt cannot pause across process boundaries because state only exists in memory. A PostgreSQL checkpointer lets the pause-resume cycle span any amount of time. A user coming back tomorrow to confirm an application is no problem.
From a higher vantage point, HITL is not just a safety mechanism — it is the trust interface between the user and the Agent.
When a user sees “Agent wants to do X, details below, approve?”, several things happen:
This is a fundamentally different trust relationship than “Agent did X in the background and told you afterward.”
Relay’s design position is: for irreversible actions, transparency and a sense of control matter more than efficiency.
“Use the best model for everything” is common PoC thinking. In production, it shows up directly on the bill.
Relay’s approach is a three-tier LLM router with precise cost tracking.
Heavy (reasoning tier)
Model: DeepSeek V4 Pro
Cost: $0.435 / 1M input, $0.87 / 1M output
Uses: Deep interview evaluation, complex reasoning, scenes needing reasoning traces
General (general-purpose tier)
Model: GLM-4.7
Cost: $0.40 / 1M input, $1.75 / 1M output
Uses: Resume optimization/tailoring, Coordinator main loop, medium-complexity tasks
Fast (batch tier)
Model: DeepSeek V4 Flash
Cost: $0.098 / 1M input, $0.196 / 1M output
Uses: JD parsing, intent classification, batch ETL, simple extraction
Each tier corresponds to specific scenarios, chosen based on reasoning complexity and call frequency — not assigned randomly.
// Cost calculation in api/llm.ts
const PRICE_TABLE: Record<string, { in: number; out: number }> = {
"deepseek/deepseek-chat-v4-pro": { in: 0.435, out: 0.87 },
"zhipu/glm-4.7": { in: 0.40, out: 1.75 },
"deepseek/deepseek-chat-v4-flash": { in: 0.098, out: 0.196 },
}
function computeCostCents(
model: string,
promptTokens: number,
completionTokens: number,
): number {
const p = PRICE_TABLE[model]
if (!p) return 0
const usd =
(promptTokens / 1_000_000) * p.in +
(completionTokens / 1_000_000) * p.out
// Convert to cents, four decimal places
return Math.round(usd * 100 * 10_000) / 10_000
}
Why track down to four decimal places in cents? A single call may cost less than $0.0001, but dozens of calls within a session accumulate quickly. Precise tracking is a prerequisite for cost observability.
The Agent layer has a post_model_hook that accumulates token usage after each model call. When session cost approaches the $0.50 cap, a downgrade is automatically triggered:
def post_model_hook(state: CoordinatorState, model_output) -> CoordinatorState:
usage = model_output.usage_metadata
cost = compute_cost(current_model, usage.input_tokens, usage.output_tokens)
new_total = state["total_cost_cents"] + cost
if new_total > 40.0: # Approaching the 50 cents cap
trigger_model_downgrade(state) # V4 Pro -> GLM-4.7 -> V4 Flash
return {**state, "total_cost_cents": new_total}
This mechanism gives each session a cost ceiling while delivering the highest quality service for as long as possible on the more expensive models.
This is the single design in Relay that I believe has the most engineering value, and the one least often implemented in other systems.
Letting AI optimize a resume has one fundamental risk: AI may invent things you never did.
“Improved team efficiency by 30%” — that 30% was made up by the AI. “Led a team of 5 engineers” — that 5 was added because the AI thought it “sounded good.”
A pure prompt constraint (“do not fabricate content”) is insufficient. The model will obey, until it does not.
Relay’s solution is to extract all verifiable entities from the AI output after resume optimization, then compare against the original resume:
FABRICATION_PATTERNS = [
r'\b\d{4}\b', # Years
r'\b\d+%\b', # Percentages
r'\$[\d,]+', # Dollar amounts
r'\b\d+\s+people\b', # Headcounts
r'\b\d+\s+engineers\b', # Engineer counts
]
async def fabrication_guard(
original: ResumeContent,
optimized: str,
max_retries: int = 2,
) -> str:
for attempt in range(max_retries + 1):
entities = extract_entities(optimized, FABRICATION_PATTERNS)
violations = find_violations(entities, original)
if not violations:
return optimized # Passed validation
if attempt == max_retries:
# Exceeded retries — fail loudly
await audit_log("fabrication_guard_failed", violations)
raise FabricationDetected(
f"Could not eliminate fabricated content in {max_retries} attempts: {violations}"
)
# Regenerate with specific violations attached
optimized = await regenerate_with_violations(original, optimized, violations)
return optimized
Key design: on failure, do not silently degrade by returning the original content. Instead raise an explicit exception and write to the audit log. This lets the engineering team track the fabrication guard’s trigger rate and continuously improve prompts.
From a product perspective, this is the fundamental difference from “AI polishing” tools: Relay’s promise to users is that AI only restates your own experiences and will never add things you never did.
Whether that promise can be trusted does not depend on how well the prompt is written. It depends on whether there is runtime validation as a backstop.
Relay’s API layer is built on Hono + Bun, but the more important story is not the framework choice — it is how the middleware is composed.
app.use(
security(), // CORS allowlist + CSP + 1MB body limit
requestId(), // Auto-inject UUID trace ID
rateLimiter(), // Redis sliding window rate limit (per IP)
auth(), // JWT validation + X-User-Id header
idempotency(), // 24h duplicate request dedup (Redis)
validation(), // Zod unified validation (body + query)
)
The idempotency middleware is the most interesting design here. The frontend attaches an Idempotency-Key header on retries (typically a request UUID). The server caches the first response in Redis for 24 hours. Subsequent requests with the same key return the cached result without triggering business logic.
This is especially important for Agent systems: if a user approves a HITL checkpoint and a network hiccup causes the frontend to retry, without idempotency the same application could be submitted twice.
Relay has a dedicated routes/idor.test.ts file with 15 IDOR (Insecure Direct Object Reference) test scenarios:
// User A attempts to access User B's resume
test("GET /resumes/:id — cannot access another user's resume", async () => {
const { id } = await createResumeForUserB()
const res = await request(app)
.get(`/api/resumes/${id}`)
.set("Authorization", `Bearer ${tokenA}`)
expect(res.status).toBe(403)
})
For a system handling resumes, cover letters, and interview records, IDOR protection is not optional. The 15-case matrix covers combinations of different resource types and different roles, providing a continuous regression baseline.
Relay’s database has 17 tables. Here are three designs with specific Agent-oriented characteristics.
-- resumes table has a track axis
ALTER TABLE resumes ADD COLUMN track text NOT NULL DEFAULT 'original'
CHECK (track IN ('original', 'optimized', 'tailored'));
-- Original resumes are immutable (trigger protection)
CREATE TRIGGER prevent_original_mutation
BEFORE UPDATE ON resumes
FOR EACH ROW
WHEN (OLD.track = 'original')
EXECUTE FUNCTION raise_mutation_error();
Three-track logic:
original: what the user uploaded, never modifiable (trust contract)optimized: AI’s general optimization of the originaltailored: a version customized for a specific JD (per-job)Each bullet point has a stable bullet_index, enabling line-by-line diff editing. This is the gap between “AI edited your resume” and “you know exactly what AI changed.”
CREATE TABLE agent_tasks (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
user_id uuid NOT NULL REFERENCES users(id),
agent_name text NOT NULL,
action text NOT NULL,
payload jsonb,
-- HITL fields
hitl_action text,
hitl_payload jsonb,
hitl_decision text, -- 'approve' | 'reject' | 'modify'
decided_at timestamptz,
-- Cost tracking
cost_cents numeric(10,4),
tokens_in int,
tokens_out int,
-- Status
status text DEFAULT 'pending',
error text,
started_at timestamptz DEFAULT now(),
ended_at timestamptz
);
This table does two things:
hitl_decision + decided_at records the user’s approval result for each actionThis gives a reliable answer to “why did the Agent take this action.”
-- jobs table has an embedding column
ALTER TABLE jobs ADD COLUMN embedding vector(1536);
CREATE INDEX jobs_embedding_idx ON jobs USING ivfflat (embedding vector_cosine_ops);
-- Match query
SELECT j.*, 1 - (j.embedding <=> $1) as score
FROM jobs j
WHERE 1 - (j.embedding <=> $1) > 0.7
ORDER BY score DESC
LIMIT 20;
JobMatchAgent generates embeddings after scraping new positions and uses cosine similarity for semantic matching. Match model weights: skills (45%) + level (25%) + location (20%) + salary (10%).
Pure vector search plus weighted scoring outperforms keyword matching on both recall and precision.
Relay wraps a layer called the “Harness” around LangGraph. This is the most production-minded design in the entire Python Agent layer.
+-------------------------------------------------+
| Relay Harness (business logic layer) |
| - Cost tracking + token budget |
| - Loop Guards (runaway prevention) |
| - Context Window management (auto-compress) |
| - Audit logging (async insert agent_tasks) |
| - Permission system (AUTO/NOTIFY/APPROVE/BLOCK) |
+-------------------------------------------------+
| LangGraph (underlying engine) |
| - create_react_agent ReAct loop |
| - StateGraph + interrupt HITL |
| - PostgresSaver checkpointer |
+-------------------------------------------------+
One of the biggest engineering risks in Agent systems is runaway loops — an Agent spiraling into tool calls until tokens are exhausted or the bill explodes.
Relay’s Loop Guards:
GUARDS = {
"max_iterations": 20, # Over 20 rounds: force summarize and stop
"token_budget": 80_000, # Over 80k tokens: compress history
"cost_limit_cents": 50.0, # Over $0.50: pause and notify user
"timeout_seconds": 300, # Over 5 minutes: abort
"consecutive_errors": 3, # 3 consecutive errors: abort
}
max_iterations is enforced via LangGraph’s recursion_limit=40 (double the actual limit as a buffer). The Harness catches the resulting GraphRecursionError, generates a summary, and exits gracefully.
When a session’s token usage exceeds 60k, older conversation history is automatically compressed:
async def compress_if_needed(state: CoordinatorState) -> CoordinatorState:
total_tokens = sum(count_tokens(m) for m in state["messages"])
if total_tokens < 60_000:
return state
messages = state["messages"]
recent = messages[-10:] # Keep most recent 5 turns (user + assistant each)
old = messages[:-10]
summary = await summarize(old) # V4Flash compresses old history
return {
**state,
"messages": [SystemMessage(summary)] + recent,
}
This design lets the Coordinator maintain very long sessions — helping you find a job might span weeks — without crashing from context window overflow.
@asynccontextmanager
async def audit(user_id: UUID, agent: str, action: str):
task_id = uuid4()
try:
yield
# Write asynchronously — does not block the main flow
asyncio.create_task(
insert_agent_task(task_id, user_id, agent, action, "success")
)
except Exception as e:
asyncio.create_task(
insert_agent_task(task_id, user_id, agent, action, "error", error=str(e))
)
raise
# Usage
async with audit(user_id, "resume_agent", "parse"):
result = await parse_resume(raw_text)
asyncio.create_task() is the key: writing the audit log does not block the main flow but guarantees eventual consistent writes.
Relay has what it calls its “core differentiator”: applications happen in the user’s own browser, not on Relay’s servers.
Server-side auto-apply has three systemic risks:
Client-side execution fundamentally bypasses all three: the user’s own browser, own IP, own authenticated session — platforms cannot distinguish “manual application” from “AI-assisted application.”
Layer 1 (~70% of fields): Local rules engine
Common fields (name / email / phone / company / title) -- direct mapping, $0 cost
Layer 2 (~25% of fields): Cloud LLM field mapping
POST /api/map-fields
in: { unknown_field: "current_compensation_type", user_profile }
out: { field -> value }
Cost ~$0.001 / job
Layer 3 (~5% of fields): Cloud LLM open-ended questions
POST /api/answer-q
in: { question, jd, resume }
out: personalized_answer
Cost ~$0.002 / job
Total LLM cost per application is approximately $0.003, implying a theoretical gross margin near 98% on a $15/month subscription.
The longer-term design is to let the server-side Agent operate the user’s already-logged-in browser directly via MCP:
Server Agent (LangGraph) <-- MCP --> Playwright MCP Chrome Extension
- Connect to user's logged-in ATS tab
- accessibility snapshot (structured DOM)
- Fill fields (visible to user)
- User personally clicks Submit
Playwright’s accessibility snapshot converts any page into a tree representation an Agent can understand, letting the Agent handle ATS forms it has never seen before. This is the most elegant “AI form-filling” architecture currently known: the browser extension needs no DOM manipulation code, and the Agent side needs no ATS-specific adapters.
Relay’s product moat is not the single-session experience — it is the data flywheel, the mechanism by which the system improves as more users use it.
User A completes a Google L5 backend interview
-> records questions + answers + AI evaluation
User B also does a Google L5 backend interview
-> system surfaces "related real questions"
User C opts into crowdsourcing
-> aggregated insight: "top 10 questions at Google L5 backend"
With each new user, the interview question bank becomes richer. This is classic network effects — applied to knowledge accumulation rather than social graphs.
Resume updated -> 'resume:updated' event
|
v
JobMatchAgent subscribes -> recompute all open job matches
|
v
'job:matched' event -> real-time frontend notification
Job scraped -> 'job:created' event
|
v
JobMatchAgent -> find matching users
|
v
Notification Service -> push message
This event-driven architecture decouples the agents — no direct calls, only event subscriptions. Each Agent grows independently and subscribes independently to the events it cares about.
At 2:00 AM daily, TrendAgent extracts skill requirements from job data, compares against the user’s resume, and generates a personalized gap report:
"Top 5 backend job demands this week:
Rust +34% (your resume: none)
Ray/distributed training +28% (your resume: some related experience)
Graph RAG +22% (your resume: none)
Suggestion: add a Rust side project bullet -- could match 23 more roles."
This turns trend data into actionable personal recommendations rather than just a market report.
From Relay’s architecture I have distilled several design decision points you can apply to your own projects:
Before deciding how many agents to create, ask these four questions:
If any one of these is “yes,” the split has a valid justification.
HITL is not as simple as adding a “Confirm” button. You need:
For any “AI should not do X” requirement, first ask: “Is there runtime validation as a backstop?”
Prompt constraints are probabilistic. Runtime validation is deterministic. Use both, but runtime validation is the last line of defense.
Not optional:
Without audit logs, an Agent system is a black box. When something goes wrong, there is no trail to follow.
Relay’s current completion:
The Agent layer code has not been started — but the design documentation is extremely complete. For anyone who wants to deeply understand multi-agent system architecture, this is a rare opportunity: design intent is clear and unspoiled by historical implementation details.
Project: github.com/cubxxw/apply-agent
The docs/architecture/ directory contains five systematic architecture documents covering the system overview, Agent architecture, Harness design, client-side delivery approach, and data model. If you are designing a similar Agent system, these are worth reading carefully.
The scarcest resource in Agent engineering right now is not intelligence — it is accumulated production engineering: how to prevent runaway loops, how to control costs, how to backstop AI mistakes, how to maintain user trust in the system.
Every architectural choice in Relay — the HITL interrupt design, the fabrication guard’s runtime validation, the three-tier LLM router, the Dual-Track resume model — answers a specific production problem. None of them are chasing the latest framework feature.
That “production-problem-driven architecture” mindset is what I believe is the most valuable thing to learn from this project.
This article was written based on Relay’s public code and architecture documentation. If you are building an Agent system, feel free to explore the project repo and consider contributing. The architecture design is open, and the code is waiting to be written.
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。