Date: 2026-05-17
Audience: IT developers working with .NET + actor models + LLM integration
Related memorizer notes (new window):
- Akka.NET Distributed Actor Model (Korean)
- Akka Agents Platform Overview (Korean)
- Akka Sample Projects (9 agents) (Korean)
- Akka Hello World Agent Tutorial (Korean)
- Akka Multi-Agent Planner (Korean)
TL;DR
Akka.io (on the JVM) ships an
Agent component that bundles LLM calls + session memory + tool calls into a single first-class abstraction. Akka.NET does not yet provide this abstraction natively, but you can build the same shape using ReceiveActor + Become transitions + supervised child actors + an external LLM client. This article walks through the design and runnable samples.Background — Why isn't there an Agent in Akka.NET?
Dimension | Akka.io (JVM / Java SDK) | |
:--- | :--- | :--- |
Actor model | ✅ Same Hewitt-1973 lineage | ✅ Akka.io ported to .NET, identical |
Agent component | ✅ First-class citizen (Akka SDK 2024+) | ❌ Missing — must be assembled by hand |
Workflow / Entity / View | ✅ Annotations + SDK runtime | ❌ Library level (Persistence·Streams separately) |
Memory management | Built-in Event Sourced Entity | Akka.Persistence (equivalent) |
Tool calling integration | LLM response → automatic dispatch | Hand-rolled (Newtonsoft + reflection) |
Akka.io's Agent SDK wraps a Context Gathering → Reasoning → Action Taking → Progress Evaluation lifecycle into one abstract class (see Akka Agents Platform note). On the .NET side you have to build the same shape with explicit
Become transitions + PersistentActor checkpoints.1. Akka.io Agent in one line
Minimal form from the official Hello World tutorial:
@Component(id = "hello-world-agent") public class HelloWorldAgent extends Agent { private static final String SYSTEM_MESSAGE = "You are a cheerful AI..."; public Effect<String> greet(String userGreeting) { return effects() .systemMessage(SYSTEM_MESSAGE) .userMessage(userGreeting) .thenReply(); } }
Methods provided by the
effects() builder:Method | Role |
:--- | :--- |
model(ModelProvider) | Pick the LLM (OpenAI / Anthropic / MCP / local) |
systemMessage(String) | System prompt |
userMessage(String) | User input |
tools(...) | Expose Java methods as tools to the LLM |
memoryProvider(...) | Session memory source |
thenReply() | Return the response |
The key insight: session is the memory key. Calling
componentClient.forAgent().inSession(userId).method(HelloWorldAgent::greet).invoke(text) automatically prepends prior history for that same userId into the system prompt.2. .NET mapping table — what becomes what
Akka.NET / .NET ecosystem | Notes | |
:--- | :--- | :--- |
Agent component | LlmAgentActor : ReceiveActor (custom) | Designed in this article |
Effect<T> builder | AgentEffectBuilder<T> (POCO) | Fluent API |
@Component(id="...") | Props.Create<LlmAgentActor>(...) • named child path | Explicit path |
componentClient.forAgent().inSession(id) | agents.GetOrCreate(sessionId) (router actor) | Or Akka.Cluster.Sharding |
Session memory (automatic) | SessionMemoryActor : PersistentActor • snapshots | Akka.Persistence |
tools(...) | IAgentTool interface + [AgentFunction] attribute | Reflection-generated JSON Schema |
model(ModelProvider) | ILlmClient (OpenAI/Anthropic/Ollama SDK wrapper) | DI-injected |
Effect<String> return | Receive<AgentRequest> → Sender.Tell(AgentResponse) | Ask pattern or one-way |
Workflow component | WorkflowActor : ReceiveActor (FSM / Behavior-based) | Transition via Become(...) |
Event Sourced Entity | PersistentActor | Same semantics |
View (CQRS read side) | Akka.Streams • projection actors | Or Akka.Persistence.Query |
memorizer-v1 already implements this mapping in production — see Memorizer Actor Collaboration chapter.3. AkkaAgent abstraction design
3-1. Message protocol
public abstract record AgentMessage; public sealed record AgentRequest( string SessionId, string UserMessage, Dictionary<string, object>? Context = null ) : AgentMessage; public sealed record AgentResponse( string SessionId, string Reply, IReadOnlyList<ToolInvocation> ToolCalls, int PromptTokens, int CompletionTokens ) : AgentMessage; public sealed record AgentError( string SessionId, string Reason, Exception? Cause = null ) : AgentMessage; public sealed record ToolInvocation(string Name, string ArgsJson, string ResultJson);
3-2. Abstract actor — lifecycle as Behavior transitions
public abstract class LlmAgentActor : ReceiveActor, IWithUnboundedStash { public IStash Stash { get; set; } = null!; private readonly ILlmClient _llm; private readonly IActorRef _memory; // SessionMemoryActor (child) private readonly IReadOnlyList<IAgentTool> _tools; private readonly string _agentId; protected LlmAgentActor( string agentId, ILlmClient llm, IReadOnlyList<IAgentTool> tools) { _agentId = agentId; _llm = llm; _tools = tools; _memory = Context.ActorOf(SessionMemoryActor.Props(), "memory"); Become(Idle); } // ---- Behavior 1: Idle (Akka.io "waiting for request") ---- private void Idle() { Receive<AgentRequest>(req => { Stash.Stash(); _memory.Tell(new LoadSessionMessages(req.SessionId), Self); Become(() => GatheringContext(req, Sender)); }); } // ---- Behavior 2: GatheringContext (Context Gathering) ---- private void GatheringContext(AgentRequest req, IActorRef caller) => Receive<SessionMessages>(loaded => { Stash.UnstashAll(); var prompt = BuildPrompt(req, loaded.Messages); Become(() => Reasoning(req, caller, prompt)); Self.Tell(prompt); }); // ---- Behavior 3: Reasoning (LLM call) ---- private void Reasoning(AgentRequest req, IActorRef caller, AgentPrompt prompt) => Receive<AgentPrompt>(async _ => { try { var raw = await _llm.CompleteAsync(prompt, _tools.Select(t => t.Schema)); Become(() => Acting(req, caller, raw)); Self.Tell(raw); } catch (Exception ex) { caller.Tell(new AgentError(req.SessionId, ex.Message, ex)); Become(Idle); } }); // ---- Behavior 4: Acting (tool dispatch) ---- private void Acting(AgentRequest req, IActorRef caller, LlmCompletion raw) => Receive<LlmCompletion>(async _ => { if (raw.ToolCalls.Count == 0) { _memory.Tell(new AppendMessages(req.SessionId, raw.AssistantMessage)); caller.Tell(new AgentResponse(req.SessionId, raw.Content, Array.Empty<ToolInvocation>(), raw.PromptTokens, raw.CompletionTokens)); Become(Idle); return; } var invocations = new List<ToolInvocation>(); foreach (var call in raw.ToolCalls) { var tool = _tools.First(t => t.Name == call.Name); var result = await tool.InvokeAsync(call.ArgsJson); invocations.Add(new ToolInvocation(call.Name, call.ArgsJson, result)); } // re-inject tool results into the prompt → fall back to Reasoning var nextPrompt = AppendToolResults(BuildPromptFromCompletion(raw), invocations); Become(() => Reasoning(req, caller, nextPrompt)); Self.Tell(nextPrompt); }); protected abstract AgentPrompt BuildPrompt(AgentRequest req, IReadOnlyList<ChatMessage> history); }
Key: Akka.io's four-stage lifecycle (Context Gathering → Reasoning → Action → Progress Evaluation) becomes four Behaviors transitioned viaBecome(...)in .NET. The state machine is now visible in the code, instead of being hidden inside SDK lifecycle annotations — so you can debug transitions with your eyes.
3-3. Tool abstraction
public interface IAgentTool { string Name { get; } string Description { get; } JsonSchema Schema { get; } Task<string> InvokeAsync(string argsJson); } [AttributeUsage(AttributeTargets.Method)] public sealed class AgentFunctionAttribute : Attribute { public string? Description { get; init; } } // Usage — reflection auto-generates IAgentTool public sealed class WeatherTool { [AgentFunction(Description = "Get current weather for a city")] public Task<WeatherResult> GetWeather(string city) => /* HTTP call */; }
3-4. Session memory (PersistentActor)
public sealed class SessionMemoryActor : ReceivePersistentActor { public override string PersistenceId => $"session-memory-{_sessionId}"; private readonly string _sessionId; private readonly LinkedList<ChatMessage> _history = new(); private const int MaxHistory = 20; public SessionMemoryActor(string sessionId) { _sessionId = sessionId; Recover<MessageAppended>(evt => Apply(evt)); Recover<SnapshotOffer>(offer => { if (offer.Snapshot is List<ChatMessage> snap) foreach (var m in snap) _history.AddLast(m); }); Command<LoadSessionMessages>(_ => Sender.Tell(new SessionMessages(_history.ToList()))); Command<AppendMessages>(cmd => Persist(new MessageAppended(cmd.Message), evt => { Apply(evt); if (_history.Count % 50 == 0) SaveSnapshot(_history.ToList()); })); } private void Apply(MessageAppended evt) { _history.AddLast(evt.Message); while (_history.Count > MaxHistory) _history.RemoveFirst(); } }
4. Runnable sample — HelloAgent (Q&A)
4-1. Concrete actor
public sealed class HelloAgentActor : LlmAgentActor { private const string SystemPrompt = "You are a cheerful AI assistant. Greet users in a new language each turn."; public HelloAgentActor(ILlmClient llm) : base("hello-agent", llm, Array.Empty<IAgentTool>()) { } protected override AgentPrompt BuildPrompt( AgentRequest req, IReadOnlyList<ChatMessage> history) { var msgs = new List<ChatMessage> { ChatMessage.System(SystemPrompt) }; msgs.AddRange(history); msgs.Add(ChatMessage.User(req.UserMessage)); return new AgentPrompt(msgs); } public static Props Props(ILlmClient llm) => Akka.Actor.Props.Create(() => new HelloAgentActor(llm)); }
4-2. Invocation — session-keyed routing
public sealed class AgentRouter : ReceiveActor { private readonly Func<string, Props> _propsFactory; private readonly Dictionary<string, IActorRef> _bySession = new(); public AgentRouter(Func<string, Props> propsFactory) { _propsFactory = propsFactory; Receive<AgentRequest>(req => { if (!_bySession.TryGetValue(req.SessionId, out var agent)) { agent = Context.ActorOf(_propsFactory(req.SessionId), $"agent-{req.SessionId}"); _bySession[req.SessionId] = agent; } agent.Forward(req); }); } } // Wire it up var system = ActorSystem.Create("agent-host"); var llm = new OpenAiClient(apiKey: ...); var router = system.ActorOf( Props.Create(() => new AgentRouter(_ => HelloAgentActor.Props(llm))), "agents"); var reply = await router.Ask<AgentResponse>( new AgentRequest("alice", "Hi, I'm Alice"), TimeSpan.FromSeconds(30)); // reply.Reply: "Hello, Alice! (English)"
4-3. Second call — memory applied automatically
reply = await router.Ask<AgentResponse>( new AgentRequest("alice", "I live in New York"), TimeSpan.FromSeconds(30)); // reply.Reply: "Bonjour, New York! Ah, la ville qui ne dort jamais. (French)"
Same
SessionId="alice" → same child actor → same SessionMemoryActor → prior history auto-prepended.5. Runnable sample — WeatherAgent (tool calls)
public sealed class WeatherAgentActor : LlmAgentActor { private const string SystemPrompt = "You are an activity planner. Use the weather tool before recommending outdoor activities."; public WeatherAgentActor(ILlmClient llm, WeatherTool weather) : base("weather-agent", llm, new IAgentTool[] { AgentToolFactory.Wrap(weather) }) { } protected override AgentPrompt BuildPrompt( AgentRequest req, IReadOnlyList<ChatMessage> history) => /* same as above */; }
When the LLM returns
tool_calls: [{name: "GetWeather", args: {city: "Madrid"}}], the Acting Behavior dispatches WeatherTool.GetWeather, appends the result to the next prompt, falls back to Reasoning, and produces the final answer. From the caller's perspective there is only one AgentResponse, but two LLM round-trips happen internally.6. End-to-end sequence
sequenceDiagram autonumber participant U as User participant R as AgentRouter participant A as LlmAgentActor<br>WeatherAgent participant M as SessionMemoryActor participant LLM as ILlmClient participant T as WeatherTool U->>R: AgentRequest alice "Madrid recs?" R->>A: Forward (session-keyed) Note over A: Behavior=Idle A->>M: LoadSessionMessages M-->>A: SessionMessages history Note over A: Behavior=GatheringContext to Reasoning A->>LLM: complete prompt + tools LLM-->>A: tool_call GetWeather Madrid Note over A: Behavior=Acting A->>T: GetWeather Madrid T-->>A: rain 18C Note over A: Behavior=Reasoning (reentry) A->>LLM: complete prompt + tool_result LLM-->>A: "Rainy. Try Prado Museum." A->>M: AppendMessages A-->>R: AgentResponse R-->>U: AgentResponse Note over A: Behavior=Idle
7. Operational considerations
Concern | Akka.io (automatic) | Akka.NET (explicit) |
:--- | :--- | :--- |
Session memory compaction (old messages) | SDK | Add MaxHistory • summarisation logic in SessionMemoryActor |
Truncating on token overflow | SDK | Pre-compute with a tokenizer in BuildPrompt |
Tool-call timeout / retry | SDK | AskTimeout • Polly + Supervisor OneForOneStrategy |
Distributed routing (session → node) | Cluster Sharding automatic | Apply Akka.Cluster.Sharding explicitly |
Observability | OpenTelemetry integration | Akka.Logger.Serilog • Phobos, or Petabridge.Cmd |
Streaming LLM responses | StreamEffect | Akka.Streams Source.Single(...).MapAsync(...) or SSE |
Fault isolation | Supervisor automatic | Declare SupervisorStrategy — Resume / Restart / Escalate |
As noted in Akka.NET vs other concurrency models, .NET trades some automation for explicit control.
8. Anti-patterns
Anti-pattern | Outcome | Right way |
:--- | :--- | :--- |
New ActorSystem per session | Memory/thread blow-up | One ActorSystem • session-keyed child actors |
Synchronous LLM call ( Task.Wait) | Mailbox stall — everything queues | Always async Receive + PipeTo / await |
Reply to Sender from inside Acting with tool results | Skips Acting → Reasoning re-entry, single round-trip only | Send to Self, keep the Become chain alive |
Make every actor a PersistentActor | High snapshot frequency, disk IO bloat | Persist session memory only, keep routers/agents in-memory |
Inline tool functions inside the Agent actor | Bloats the agent's responsibility, hard to test | Inject IAgentTool separately, Agent just composes |
9. Porting matrix — Akka.io official samples → Akka.NET
Mapping the 9 official Akka samples into the abstraction (
LlmAgentActor + AgentRouter + SessionMemoryActor) introduced above:Akka.io sample | Core components | Akka.NET mapping | Difficulty |
:--- | :--- | :--- | :---: |
Agent • session memory | HelloAgentActor (§4) | ⭐ | |
Agent × 3 + Workflow • Entity | §9-1 TripPlannerWorkflowActor • 3 agents | ⭐⭐⭐ | |
RAG + Streaming • Workflow | §9-2 RagChatAgentActor • Indexer Workflow | ⭐⭐⭐ | |
Agent • 2 tools + Timers • EventSourcedEntity | §9-3 CustomerServiceAgentActor | ⭐⭐⭐ | |
Agent Memory + Workflow • Entity | Multi-agent variant — same pattern | ⭐⭐ | |
Summarisation + Workflow • Entity | RAG + classification variant of §9-2 | ⭐⭐⭐ | |
IoT trend + anomaly + agent | Akka.Streams + Agent composition | ⭐⭐⭐⭐ | |
Entity • Timed Action • Anthropic | Cron scheduler + Agent | ⭐⭐ | |
Tools + Spring AI | §5 WeatherAgent extension | ⭐⭐ |
9-1. Multi-Agent Planner (workflow orchestration)
Java code from Akka official Step 4:
@ComponentId("plan-trip") public class PlanTripWorkflow extends Workflow<State> { @StepName("get-weather") private StepEffect getWeatherStep() { var weather = componentClient .forAgent().method(WeatherAgent::query) .invoke(currentState().query()); return stepEffects() .updateState(currentState().withWeather(weather)) .thenTransitionTo(PlanTripWorkflow::suggestActivityStep); } @StepName("suggest-activity") private StepEffect suggestActivityStep() { var context = "Weather: " + currentState().weather() + "\nPreferences: " + componentClient .forEntity(currentState().userId()) .method(PreferencesEntity::getPreferences) .invoke(); var suggestion = componentClient .forAgent().method(ActivityAgent::query).invoke(context); return stepEffects() .updateState(currentState().withSuggestion(suggestion)) .thenPause(); } }
Akka.NET port — no
Workflow component, so combine FSM actor + Akka.Persistence:public sealed record PlanTripStart(string UserId, string Query); public sealed record PlanTripState( string UserId, string Query, string Weather = "", string Suggestion = ""); public sealed record WeatherReady(string Weather); public sealed record SuggestionReady(string Suggestion); public sealed class TripPlannerWorkflowActor : ReceivePersistentActor { public override string PersistenceId => $"trip-planner-{_userId}"; private readonly string _userId; private readonly IActorRef _weatherAgent; private readonly IActorRef _activityAgent; private readonly IActorRef _prefsEntity; private PlanTripState _state = null!; public TripPlannerWorkflowActor( string userId, IActorRef weather, IActorRef activity, IActorRef prefs) { _userId = userId; _weatherAgent = weather; _activityAgent = activity; _prefsEntity = prefs; Become(Idle); // Persistence recovery omitted } private void Idle() { Command<PlanTripStart>(cmd => { _state = new PlanTripState(cmd.UserId, cmd.Query); // Step 1: Get Weather _weatherAgent.Tell(new AgentRequest(cmd.UserId, cmd.Query)); Become(GettingWeather); }); } private void GettingWeather() => Command<AgentResponse>(resp => { Persist(new WeatherReady(resp.Reply), evt => { _state = _state with { Weather = evt.Weather }; _prefsEntity.Tell(new GetPreferences(_userId), Self); Become(GettingPreferences); }); }); private string _preferences = ""; private void GettingPreferences() => Command<PreferencesResponse>(resp => { _preferences = resp.Value; var ctx = $"Weather: {_state.Weather}\nPreferences: {_preferences}"; _activityAgent.Tell(new AgentRequest(_userId, ctx)); Become(GettingSuggestion); }); private void GettingSuggestion() => Command<AgentResponse>(resp => { Persist(new SuggestionReady(resp.Reply), evt => { _state = _state with { Suggestion = evt.Suggestion }; Context.Parent.Tell(_state); Become(Idle); }); }); }
Key mapping:
componentClient.forAgent().method(...).invoke(...)=agentActorRef.Tell(...)(async, result viaReceive<AgentResponse>)
transitionTo(NextStep)=Become(NextStep)
- Automatic checkpointing =
PersistentActor.Persist(evt, handler)
thenPause()=Become(WaitingForExternalInput)(e.g. wait for user approval)
9-2. RAG Chat Agent (streaming + vector search)
Java code from Akka official RAG tutorial:
@Component(id = "ask-akka-agent") public class AskAkkaAgent extends Agent { public Effect<StreamingResponse<String>> chat(String question) { return effects() .systemMessage("You are a helpful Akka assistant.") .userMessage(question) .thenReplyStreaming(); } }
Akka.NET port —
Akka.Streams.Source + a RAG helper actor:public sealed record RagQueryRequest(string SessionId, string Question); public sealed record RagChunk(string SessionId, string Token); public sealed record RagDone(string SessionId, int TokenCount); public sealed class RagChatAgentActor : LlmAgentActor { private readonly IActorRef _ragHelper; // vector-search actor private readonly IStreamingLlmClient _llmStream; protected override AgentPrompt BuildPrompt( AgentRequest req, IReadOnlyList<ChatMessage> history) { // 1) Embed the question and fetch top-5 similar docs (Ask pattern) var docs = _ragHelper.Ask<TopKDocs>( new SimilaritySearch(req.UserMessage, K: 5), TimeSpan.FromSeconds(3)).Result; // 2) Prepend retrieved docs as system context var ctx = string.Join("\n\n", docs.Items.Select(d => $"[doc:{d.Id}] {d.Text}")); var msgs = new List<ChatMessage> { ChatMessage.System("Answer based on the docs below.\n\n" + ctx) }; msgs.AddRange(history); msgs.Add(ChatMessage.User(req.UserMessage)); return new AgentPrompt(msgs); } // Streaming-only entry point (Reasoning Behavior splits off) public IAsyncEnumerable<RagChunk> ChatStreaming(RagQueryRequest req) => Source.From(_llmStream.StreamComplete(BuildPrompt( new AgentRequest(req.SessionId, req.Question), Array.Empty<ChatMessage>()))) .Select(token => new RagChunk(req.SessionId, token)) .RunAsAsyncEnumerable(materializer); }
The Indexer Workflow uses the same pattern —
IndexerWorkflowActor walks a directory, then chunks → embeds → writes into the vector DB using Akka.Streams Source.From(files).MapAsync(8, ...).9-3. Real Estate CS Agent (2 tools + Timer + EventSourced)
The core of the official Real Estate CS Agent: 2 tools + Workflow + Timer + EventSourced Entity.
public sealed class CustomerServiceAgentActor : LlmAgentActor { public CustomerServiceAgentActor( ILlmClient llm, IActorRef emailGateway, // Tool 1: send email IActorRef customerEntity) // Tool 2 + Entity: persist customer info : base("cs-agent", llm, new IAgentTool[] { AgentToolFactory.Wrap(new SendEmailTool(emailGateway)), AgentToolFactory.Wrap(new SaveCustomerTool(customerEntity)), }) { } protected override AgentPrompt BuildPrompt(...) => /* "If customer info is incomplete, call SendEmail to follow up; if complete, call SaveCustomer." */; } // Timer: send a reminder 24h later if no follow-up arrives public sealed class CustomerInquiryWorkflowActor : ReceivePersistentActor { public CustomerInquiryWorkflowActor(IActorRef agent, ITimerScheduler timers) { Command<EmailReceived>(email => { agent.Tell(new AgentRequest(email.Sender, email.Body)); Become(WaitingForFollowUp); timers.StartSingleTimer( "reminder", new SendReminder(email.Sender), TimeSpan.FromHours(24)); }); } }
The crucial detail: a tool isn't just a function — it wraps another actor's
ActorRef. When the LLM calls SendEmail, it dispatches _emailGateway.Tell(new SendEmail(...)), so even the email send happens inside the same supervision tree.9-4. Common porting rules
:--- | :--- |
@Component(id="x") | Context.ActorOf(Props, "x") (child path) |
@StepName("get-weather") | Become(GetWeather) function name |
componentClient.forAgent().method(M::f).invoke(x) | agentRef.Ask<Resp>(req, timeout) or Tell • Receive<Resp> |
componentClient.forEventSourcedEntity(id).method(M::f).invoke() | Cluster.Sharding.Get(system).ShardRegion("...").Tell(env) |
effects().thenReplyStreaming() | Source.From(stream).RunAsAsyncEnumerable(mat) |
@Tool(description="...") | [AgentFunction(Description="...")] • reflection |
Workflow thenPause() | Become(WaitingExternal) • wake on external message |
Workflow thenTransitionTo(X::step) | Call Become(Step) |
TL;DR: Akka.io's annotation-driven lifecycle maps 1:1 to .NET's explicitBecometransitions +PersistentActorcheckpoints. You lose a touch of automation, but the state machine becomes visible in code — which pays off massively during debugging and operations.
10. Next steps
- Fork a sample repo: refactor memorizer-v1's
ChatBotActorinto the 4-Behavior pattern from this article
- Multi-Agent synergy: replicate the Activity/Weather/Summarizer 3-agent collaboration from the Akka Multi-Agent Planner using an Akka.NET router + child agents
- Akka.Cluster.Sharding integration: route
sessionId → ShardIdto scale across multiple nodes
- Wire in MCP tools: wrap an MCP server call inside an
IAgentToolimplementation and expose it to the LLM
11. References
- Akka.io official Agent guide: https://doc.akka.io/java/agents.html
- Akka.NET official: https://getakka.net/
- Memorizer-v1 GitHub: https://github.com/psmon/memorizer-v1
Tags
#AkkaNET #ActorModel #LLMAgent #DotNet #AkkaIO #AgenticAI #DistributedSystems #PersistentActor #ToolCalling #2026-05