12-Factor Agents Framework
repository·main·Indexed 12 days ago
https://github.com/humanlayer/12-factor-agentsA framework of 12 engineering principles for building production-grade LLM applications. It emphasizes reliability, scalability, and maintainability by treating agents as structured software, utilizing the Agent Loop pattern (Determine Next Step, Execute Tool, Append Result, Repeat) and integrating tools like BAML for structured outputs and HumanLayer for asynchronous human approvals.
What's inside 12-Factor Agents
- 12-Factor Agents is a set of engineering principles designed to help developers build reliable, scalable, and maintainable LLM-powered software. Inspired by the 12-Factor App methodology, these principles focus on moving away from simple "prompt + tools + loop" patterns toward robust software engineering practices for agents. The goal is to create agents that are comprised of mostly deterministic software with LLM steps strategically placed to provide value, rather than relying on unpredictable agentic loops for core logic.
Use the Jupyter Notebook Testing Framework
mainThe Jupyter Notebook Testing Framework provides an iteration loop for validating notebook implementations through four stages:
- Generate: Create test notebooks with specific functionality.
- Execute: Run notebooks in a simulated Google Colab environment.
- Analyze: Examine executed notebooks for expected outputs and behaviors.
- Report: Determine pass/fail results.
This framework is designed to ensure that notebook code behaves correctly in a clean, isolated environment similar to Google Colab.
Implement Asynchronous Human Approvals via Webhooks
mainFor production workloads, avoid 'synchronous mode' where the server polls for human responses. Instead, implement an asynchronous pattern using webhooks to 'launch, pause, and resume' agent execution.
Architecture Pattern:
- Initialize HumanLayer in your server.
- Asynchronous
/threadendpoint: When a request comes in, the endpoint should start processing and return a response immediately (e.g., including astate.threadId). - Pause Execution: If the agent needs human input (e.g., for
request_more_informationordone_for_now), create a human contact and end the current processing loop. - Webhook Endpoint: Implement a
/webhookendpoint to receive the human's response from HumanLayer. - Resume Execution: Use a
handleHumanResponsefunction to process the incoming webhook and resume the agent's logic.
This approach follows the principle of launching/pausing/resuming with simple APIs, ensuring the server isn't sitting in a blocking loop while waiting for a human.
Implement reliable agents using the 'micro agent' pattern
mainInstead of building a single large agent loop, a more reliable pattern is to use micro agents embedded within a broader, deterministic Directed Acyclic Graph (DAG).
In this pattern, deterministic code orchestrates the high-level workflow, and LLMs are used only to manage well-scoped, specific sets of tasks. This approach provides several benefits:
- Reliability: Prevents the agent from 'spinning out' due to massive context windows.
- Human-in-the-loop: Makes it easy to incorporate live human feedback into specific workflow steps.
- Control: Allows the developer to maintain control over the overall state machine while leveraging LLM reasoning for specific transitions.
Own your prompts instead of using framework abstractions
mainAvoid using 'black box' agent frameworks that hide prompt engineering behind high-level abstractions like
role,goal, orpersonality. While these are helpful for prototyping, they make it difficult to tune specific tokens or reverse-engineer the exact instructions being sent to the model.Instead, treat prompts as first-class code. This means explicitly defining the system and user instructions, often using templating tools or domain-specific languages (DSLs) like BAML. This approach allows you to treat the prompt as the primary interface between your application logic and the LLM.
Benefits of owning your prompts:
- Full Control: Write exact instructions without hidden abstractions.
- Testing and Evals: Apply standard software testing and evaluation practices to your prompts.
- Iteration: Modify prompts quickly based on real-world performance data.
- Transparency: Maintain a clear understanding of exactly what instructions the agent is receiving.
- Role Hacking: Leverage non-standard usage of user/assistant roles or specific API behaviors (e.g., model gaslighting or legacy completion APIs).
# AVOID THIS 'BLACK BOX' PATTERN: agent = Agent( role="...", goal="...", personality="...", tools=[tool1, tool2, tool3] ) task = Task( instructions="...", expected_output=OutputModel ) result = agent.run(task)Implement the Agent Loop and Thread model
mainA 12-factor agent relies on a
Threadto manage state and anagentLoopto process turns.Thread: A class that holds an array ofEventobjects. It includes aserializeForLLM()method to convert the conversation history into a format (like JSON) suitable for the LLM prompt.agentLoop: An async function that takes aThread, serializes it, calls the BAML-generated client function (e.g.,b.DetermineNextStep), and returns the structured response.Event: A type representing a single interaction, typically containing atype(e.g.,user_input) anddata.
export interface Event { type: string data: any; } export class Thread { events: Event[] = []; constructor(events: Event[]) { this.events = events; } serializeForLLM() { return JSON.stringify(this.events); } } export async function agentLoop(thread: Thread): Promise<AgentResponse> { const nextStep = await b.DetermineNextStep(thread.serializeForLLM()); return nextStep; }Make your agent a stateless reducer
mainFactor 12 of the 12-Factor Agents principles suggests designing agents as stateless reducers. Instead of maintaining internal, mutable state that can become inconsistent or difficult to debug, an agent should behave like a reducer function: it takes the current state and an incoming event/action as input, and returns a new state. This pattern (often implemented via afoldlorreduceoperation over a stream of events) ensures that the agent's state is a deterministic function of its history, making it easier to test, replay, and scale.Unify execution state and business state
mainTo build reliable AI agents, aim to unify execution state and business state into a single source of truth. This reduces complexity and makes the agent's lifecycle easier to manage.
Definitions
- Execution state: Metadata about the agent's current progress, such as the current step, next step, waiting status, or retry counts.
- Business state: The actual content of the workflow, such as the list of OpenAI messages, tool calls, and tool results.
The Goal: State as Context
Whenever possible, engineer your application so that the execution state can be inferred directly from the context window. While some data (like session IDs or password contexts) must remain outside the context window, your goal should be to minimize these external dependencies by embracing the principle of owning your context window.
Benefits of Unified State
- Simplicity: You maintain only one source of truth for all state.
- Serialization: The entire agent thread becomes trivially serializable and deserializable.
- Debugging: The complete history of what happened and why is visible in one place.
- Flexibility: You can add new state information simply by introducing new event types.
- Recovery: You can resume an agent from any point by simply loading the thread.
- Forking: You can fork a thread at any point by copying a subset of the thread into a new context or state ID.
- Observability: It is easy to convert a unified thread into human-readable markdown or a rich Web UI.
Own your context window
mainInstead of relying solely on standard message-based formats (like OpenAI's chat completions API), you should treat the context window as a custom-engineered input. An agent's input to an LLM is essentially:
"here's what's happened so far, what's the next step".Effective context engineering involves managing:
- Prompts and instructions
- Retrieved data (e.g., RAG documents)
- State and history (tool calls, results, past events)
- Memory (past messages from related conversations)
- Output instructions (structured data requirements)
By owning the context format, you can optimize for Information Density, Error Handling (e.g., hiding resolved errors), Safety (filtering sensitive data), Flexibility, and Token Efficiency.
Benefits of structured human interaction tools
mainUsing structured tool calls for human interaction provides several architectural advantages:
- Clear Instructions: Specific tool definitions allow the LLM to be more precise about the type of human contact required.
- Inner vs Outer Loop: Enables 'Outer Loop' workflows where the agent is triggered by events (like a cron job) rather than just a user chat interface.
- Multiple Human Access: Structured events make it easy to track and coordinate input from different users.
- Multi-Agent Support: The abstraction can be extended to
Agent->Agentrequests. - Durability: When combined with state management (Factor 6), it creates reliable, introspectable multiplayer workflows.
Apply Factor 13: Pre-fetch context to reduce token round trips
mainTo build more efficient agents, avoid prompting the model to call tools that you can predict it will need. Instead of waiting for the model to emit a tool-call intent (which costs a round trip of tokens), call the tool deterministically in your application code and include the results in the context window before the model even makes its first decision.
There are two primary patterns for implementing this:
- Explicit Parameter Injection: Fetch the data and pass it as a direct argument to your
determine_next_stepfunction (or equivalent decision-making call). This is useful when the data is highly relevant to the immediate decision. - Thread/Event Injection: Fetch the data and append it to your
threadoreventslist as a completed event. This allows you to keep your decision-making function signature simple and relies on the model's ability to process the history of the conversation/execution.
Core Principle: If you already know what tools you'll want the model to call, call them DETERMINISTICALLY and let the model do the hard part of figuring out how to use their outputs.
# Pattern 1: Explicit Parameter Injection thread = {"events": [initial_message]} git_tags = await fetch_git_tags() # Pass the pre-fetched data directly to the decision function next_step = await determine_next_step(thread, git_tags) while True: match next_step.intent: case 'deploy_backend_to_prod': deploy_result = await deploy_backend_to_prod(next_step.data.tag) thread["events"].append({ "type": 'deploy_backend_to_prod', "data": deploy_result, }) case 'done_for_now': await notify_human(next_step.message) break- Explicit Parameter Injection: Fetch the data and pass it as a direct argument to your
Standard vs Custom Context Formats
mainMost LLM clients use a standard message-based format consisting of
system,user,assistant, andtoolroles. While convenient, this format may not be the most token- or attention-efficient for complex agents.An alternative is to build a custom context format—such as using XML-style tags—and pack the entire history into a single
usermessage. This allows you to structure events (like Slack messages, tool intents, and tool results) in a way that maximizes the LLM's ability to parse the sequence of events.[ { "role": "system", "content": "You are a helpful assistant..." }, { "role": "user", "content": | Here's everything that happened so far: <slack_message> From: @alex Channel: #deployments Text: Can you deploy the backend? </slack_message> <list_git_tags> intent: "list_git_tags" </list_git_tags> <list_git_tags_result> tags: - name: "v1.2.3" commit: "abc123" date: "2024-03-15T10:00:00Z" </list_git_tags_result> what's the next step? } ]