Orkas Orkas
Download GitHub
HomeInício首页ホーム BlogBlog博客ブログ ArchitectureArquitetura架构アーキテクチャ
ArchitectureArquitetura架构アーキテクチャ

Multi-Agent Orchestration in Practice: How Orkas Runs a Lead Agent and Its Sub-AgentsOrquestração multiagente na prática: como Orkas administra um agente líder e seus subagentes多 Agent 编排实战:Orkas 如何调度一个主 Agent 和它的子 Agentマルチ Agent 編成の実践:Orkas が主 Agent とサブ Agent を動かす仕組み

Inside Orkas's multi-agent orchestration: a lead agent turns one request into a plan, dispatches sub-agents by dependency, passes context between steps, and heals from failure.Dentro da orquestração multiagente do Orkas: um agente líder transforma uma solicitação em um plano, despacha subagentes por dependência, transmite contexto entre etapas e cura falhas.拆解 Orkas 的多 Agent 编排:主 Agent 把一句请求变成一份计划,按依赖派遣子 Agent,在步骤之间传递上下文,并在出错时自愈。Orkas のマルチ Agent 編成を分解します。主 Agent が一つの依頼を計画に変え、依存関係に沿ってサブ Agent を派遣し、結果を渡しながら失敗から回復します。

The previous article was about making one agent reliable: the run loop, tool routing, context compaction, crash-safe sessions. That layer answers "how does a single agent get through a task without falling over?" This article is about the layer above it — what happens when one agent isn't enough, and a job has to be split across a team.

This is what people usually mean by multi-agent orchestration: a lead agent that owns the conversation breaks a request into pieces, hands each piece to a specialized sub-agent, waits for the right ones to finish before starting the next, passes results forward, and keeps the whole thing on the rails when a step fails. Orkas runs this entirely on the user's own machine. Below is how that orchestration layer is built — code scrubbed and generalized, but the structure is real.

Lead agent and sub-agents

The mental model is a small team with a clear chain of command. A lead agent (we call it the commander) owns the conversation and the overall context. It doesn't do all the work itself; its job is to decide what needs doing, in what order, and by whom. The sub-agents are specialists — each configured with its own system prompt, its own allowed tools, and its own set of skills. A sub-agent is good at one slice of the work and is invoked when that slice comes up.

Two things make this more than a buzzword. First, sub-agents and skills are first-class units, not prompt tricks: a sub-agent is a real, separately-configured agent, and dispatching to one is a real handoff with its own context. Second, the coordination isn't left to the model's good intentions — it's driven by an explicit artifact that the system, not the model, keeps honest. That artifact is the plan.

A plan is a graph, not a script

When the lead agent decides a request needs more than one step, it writes a plan. The plan is not free-form prose and it is not a linear checklist — it's a small dependency graph (a DAG). Each node is a step, and a step carries everything the orchestrator needs to dispatch it:

interface PlanStep {
  index: number;            // 1-based, stable, never renumbered
  title: string;            // human-readable, shown in the UI
  assignee: string;         // who runs it: "user" | "commander" | a sub-agent
  input?: string;           // the dispatch payload — a template (see below)
  wait_for?: number[];      // upstream step indexes; defaults to [index - 1]
  on_failure?: "abort_plan" | "continue" | "ask_commander";

  // --- runtime state, owned by the orchestrator, NOT written by the model ---
  status: "pending" | "in_progress" | "done" | "failed" | "skipped" | "blocked";
  output_summary?: string;  // short summary of what the step produced
  output_files?: string[];  // files the step produced
  failure_reason?: string;
}

The wait_for field is what turns a list into a graph. By default a step waits for the one before it (a simple chain), but a step can declare it depends on several earlier steps — "summarize" might wait for both "research the market" and "survey competitors." That's a diamond, not a line, and the orchestrator treats it as one.

Who owns the truth: the orchestrator, not the model

Look again at the split in that struct. The model fills in the intent — titles, assignees, inputs, dependencies — when it first writes the plan. But everything about execution statestatus, output_summary, failure_reason — is owned exclusively by the orchestrator. The model proposes the plan once; it never gets to mark its own steps "done."

This separation is deliberate, and it's the single most important decision in the whole design. A language model is perfectly capable of cheerfully announcing "step 3 complete" when step 3 errored, or of losing track of which steps are still outstanding halfway through a long conversation. If state lived in the model's head, the plan would drift from reality. By making state a structured artifact that only the executor writes — and writes only in response to a step actually finishing — the plan stays an accurate mirror of what has really happened. The model decides the shape of the work; the runtime decides what is true about its progress.

Dispatching the steps that are ready

Once a plan exists, a small engine — the executor — drives it forward. The core operation is "find the steps that are ready and dispatch them." A step is ready when it is still pending and every step it waits for has reached a terminal, successful-enough state:

function findReadySteps(plan): PlanStep[] {
  return plan.steps.filter((s) => {
    if (s.status !== "pending") return false;
    const deps = s.wait_for ?? (s.index > 1 ? [s.index - 1] : []);
    return deps.every((d) => isTerminal(plan.step(d).status)); // done or skipped
  });
}

This runs not on a timer but in response to events. Every time a step finishes — a sub-agent returns, the commander wraps up a synthesis turn — the executor reconciles: it records the outcome of the step that just finished, then re-scans for whatever became ready as a result, and dispatches it. Orchestration is this reconcile loop, turning over and over until no steps remain.

The dispatch goes through the same chat, not a side channel

Here's a choice that keeps the system honest: dispatching a step doesn't use a hidden RPC channel. It posts a message into the very same group conversation the user is watching, from the lead agent, @-mentioning the sub-agent. To the sub-agent, being dispatched is indistinguishable from being addressed in chat — it just runs its normal turn. There's no second execution path to keep in sync with the first.

An assignee can be one of three kinds, and each dispatches a little differently:

  • A sub-agent — the common case. The executor resolves the agent's name to its id and posts @<agent> <rendered input> from the commander. The sub-agent picks it up and runs a full agent turn.
  • The user — when a step genuinely needs human input, the step turns into a form and pauses the plan (more on this below). Nothing downstream proceeds until the user answers.
  • The commander itself — for synthesis or decision steps ("read everything above and write the summary"). This is a private wake-up that doesn't post a redundant user-visible message; the lead agent simply takes a turn with the gathered context in hand.

Passing context from one step to the next

A team is only useful if work flows between its members. The mechanism is the input template. When the lead agent writes a step, the input isn't a frozen string — it can reference earlier results, and the executor renders those references at dispatch time:

// step 3.input, as written by the lead agent
"Using the findings below, draft the launch note.\n\n{{step_1.output_summary}}"

// what the sub-agent actually receives at dispatch time
"Using the findings below, draft the launch note.\n\n- Market is growing ~20% YoY; two incumbents…"

Note what gets passed forward: output_summary, a short summary of each finished step — not its entire transcript. This is a budget decision, and it's the same instinct as the harness's context compaction. If every downstream step inherited the full token-by-token history of everything before it, context would balloon and cost would explode after just a few hops. Summaries keep each handoff cheap and keep each sub-agent focused on what it actually needs from upstream, rather than wading through how upstream got there. The initial user message and any attachments are carried along too, so a step three hops down the chain still knows the original ask.

Serial by default — and why

You might expect that when several steps come back ready at once — two branches of a diamond, say — the orchestrator fires them all in parallel. It could; instead, today it dispatches one ready step at a time, the earliest by index, and lets the rest wait for the next reconcile. Running a whole team strictly one-at-a-time is a deliberate, conservative choice, and it's worth being honest about why.

The reason is correctness under concurrency. Picture two sub-agents finishing at nearly the same instant. Both completions trigger a reconcile; both reconciles read the plan; both see the same downstream step still sitting at pending — and both dispatch it. Now the same step runs twice. To make that impossible, every read-modify-dispatch cycle on a given conversation is serialized behind a per-conversation lock:

// all state-changing paths for one conversation run under one mutex
planLock(uid, cid).runExclusive(async () => {
  const plan = await readPlan(uid, cid);
  applyOutcomeOfFinishedStep(plan);   // mark done / failed / skipped
  await dispatchReady(plan);          // dispatch the next ready step
});

The lock guarantees that "record what finished" and "decide what's next" happen as one indivisible unit, so a downstream step can never be dispatched twice. With that lock in place, dispatching steps one at a time is the simplest thing that is obviously correct. Genuine parallel fan-out is a tractable extension on top of this foundation — but the foundation is a serialized, race-free executor, and that ordering of priorities (correct first, fast later) is the point.

When a step goes wrong

Running on a real machine against external model APIs, failure is routine, and the orchestrator sorts it into a few cases instead of treating every error the same.

First it asks whether the failure was merely transient — a dropped connection, a rate limit, a blip. If so, and the step hasn't already burned through a small retry budget, the step is quietly rolled back to pending so the next reconcile re-dispatches it. (This sits above the harness's own in-run retries; the plan only re-dispatches after the agent's own attempts are exhausted, with a hard cap so a truly broken step can't loop forever.)

If the failure is real, the step's declared on_failure policy decides what the team does next:

  • abort_plan — this step mattered enough that nothing downstream makes sense without it. Mark it failed, and cascade: every still-pending step is marked skipped. The plan stops cleanly rather than building on a missing foundation.
  • continue — this step was optional. Mark it skipped and let downstream steps proceed as if it simply produced nothing.
  • ask_commander (the default) — neither blindly abort nor blindly continue. Mark it failed and wake the lead agent to look at what happened and decide — retry differently, route around it, or stop and ask the user.

There's a sixth status worth calling out: blocked. A sub-agent partway through its step may realize it needs something only the user can provide, and surface a form or a question. The step doesn't fail — it goes to blocked, and the whole plan pauses. The moment the user answers, the executor reconciles and the team picks up exactly where it left off. A blocked plan is a paused plan, not a broken one.

Every step is a full agent run

It's worth closing the loop back to the previous article. When the orchestrator dispatches a step to a sub-agent, that sub-agent doesn't run some stripped-down routine — it runs the full harness loop: its own streaming run loop, its own tool calls, its own context window with compaction, its own crash-safe session. Orchestration sits cleanly on top of the single-agent runtime; it never reaches inside it. The lead agent decides the shape of the work and the order of the handoffs; each sub-agent, once handed its slice, is a complete agent in its own right.

That layering is why the two articles compose. The harness makes one agent trustworthy for one task. The orchestrator composes several trustworthy agents into a team that can take on a task too big, or too varied, for any one of them.

A few decisions that mattered

The orchestrator owns execution state, the model owns intent. The model proposes the plan; only the runtime marks steps done, failed, or skipped, and only in response to something actually happening. This one boundary is what keeps the plan an honest mirror of reality instead of the model's optimistic guess.

Dispatch through the same conversation, not a side channel. A dispatched step is just a message from the lead agent to a sub-agent. One execution path, nothing hidden to drift out of sync, and the user can watch the team work in the same thread they're already reading.

Summaries, not transcripts, between steps. Each handoff carries a short summary of upstream output. It keeps context budgets sane across long chains and keeps each sub-agent focused on what it needs, not on how the previous one got there.

Correct before parallel. A per-conversation lock serializes every read-modify-dispatch cycle, and steps dispatch one at a time. Strictly serial is the version that is obviously free of double-dispatch races; parallel fan-out is an optimization to layer on a foundation that's already correct.

Wrapping up

Orkas's orchestration layer has no exotic algorithm at its heart. Its value is in a few boundaries held firmly: a plan that is a dependency graph rather than a script; execution state owned by the runtime rather than the model; dispatch that flows through the same chat the user is watching; context that moves between steps as summaries; and an executor that puts correctness ahead of concurrency. Each is simple on its own. Together they turn a single reliable agent into a team that divides labor, passes work forward, and recovers when a piece of it goes wrong.

If you want the layer underneath this one, read how a single agent is engineered to run reliably. If you want the layer that makes each agent get better with use, read how Orkas agents learn from their own work. And if you would rather direct this layer than build it, Orkas ships it as open-source AI agent orchestration that runs on your own machine.

O artigo anterior tratava de tornar um agente confiável: loop de execução, roteamento de ferramentas, compactação de contexto, sessões seguras contra falhas. Essa camada responde "como um único agente realiza uma tarefa sem cair?" Este artigo é sobre a camada acima dela: o que acontece quando um agente não é suficiente e um trabalho precisa ser dividido entre uma equipe.

Isso é o que as pessoas geralmente querem dizer com orquestração multiagente: um agente líder, dono da conversa, divide uma solicitação em partes, entrega cada parte a um subagente especializado, espera que as corretas terminem antes de iniciar a próxima, repassa os resultados e mantém tudo sob controle quando uma etapa falha. Orkas executa isso inteiramente na máquina do usuário. Veja abaixo como essa camada de orquestração é construída: código limpo e generalizado, mas a estrutura é real.

Agente líder e subagentes

O modelo mental é uma equipe pequena com uma cadeia de comando clara. Um agente líder (nós o chamamos de comandante) é o dono da conversa e do contexto geral. Ele não faz todo o trabalho sozinho; sua função é decidir o que precisa ser feito, em que ordem e por quem. Os subagentes são especialistas — cada um configurado com seu próprio prompt de sistema, suas próprias ferramentas permitidas e seu próprio conjunto de habilidades. Um subagente é bom em uma parte do trabalho e é invocado quando essa parte surge.

Duas coisas fazem com que isso seja mais do que uma palavra da moda. Primeiro, subagentes e habilidades são unidades de primeira classe, e não truques imediatos: um subagente é um agente real, configurado separadamente, e despachar para um deles é uma transferência real com seu próprio contexto. Em segundo lugar, a coordenação não é deixada às boas intenções do modelo – é impulsionada por um artefacto explícito que o sistema, e não o modelo, mantém honesto. Esse artefato é o plano.

Um plano é um gráfico, não um script

Quando o agente principal decide que uma solicitação precisa de mais de uma etapa, ele escreve um plano. O plano não é uma prosa de formato livre e não é uma lista de verificação linear – é um pequeno gráfico de dependência (um DAG). Cada nó é uma etapa, e uma etapa contém tudo o que o orquestrador precisa para despachá-lo:

interface PlanStep {
  índice: número;            // Baseado em 1, estável, nunca renumerado
  título: sequência;            // legível por humanos, mostrado na UI
  cessionário: string;         // quem o executa: "usuário" | "comandante" | um subagente
  entrada?: string;           // a carga útil do despacho — um modelo (veja abaixo)
  esperar_por?: número[];      // índices de etapas upstream; o padrão é [índice - 1]
  on_failure?: "abort_plan" | "continuar" | "ask_commander";

  // --- estado de tempo de execução, de propriedade do orquestrador, NÃO escrito pelo modelo ---
  status: "pendente" | "em_progresso" | "feito" | "falhou" | "ignorado" | "bloqueado";
  resumo_saída?: string;  // breve resumo do que a etapa produziu
  arquivos_de_saída?: string[];  // arquiva o passo produzido
  motivo_da_falha?: string;
}

O campo wait_for é o que transforma uma lista em um gráfico. Por padrão, uma etapa espera pela etapa anterior (uma cadeia simples), mas uma etapa pode declarar que depende de várias etapas anteriores - "resumir" pode esperar tanto por "pesquisar o mercado" quanto por "pesquisar concorrentes". Isso é um diamante, não uma linha, e o orquestrador o trata como tal.

Quem é o dono da verdade: o orquestrador, não o modelo

Veja novamente a divisão nessa estrutura. O modelo preenche a intenção — títulos, responsáveis, entradas, dependências — quando escreve o plano pela primeira vez. Mas tudo sobre o estado de execuçãostatus, output_summary, failure_reason — é de propriedade exclusiva do orquestrador. O modelo propõe o plano uma vez; ele nunca marca suas próprias etapas como "concluídas".

Essa separação é deliberada e é a decisão mais importante em todo o design. Um modelo de linguagem é perfeitamente capaz de anunciar alegremente "etapa 3 concluída" quando a etapa 3 falhou, ou de perder o controle de quais etapas ainda estão pendentes no meio de uma longa conversa. Se o Estado vivesse na cabeça do modelo, o plano afastar-se-ia da realidade. Ao tornar o estado um artefato estruturado que apenas o executor escreve – e escreve apenas em resposta a uma etapa realmente concluída – o plano permanece um espelho preciso do que realmente aconteceu. O modelo decide a forma da obra; o tempo de execução decide o que é verdade sobre seu progresso.

Despachando as etapas que estão prontas

Quando um plano existe, um pequeno mecanismo — o executor — o impulsiona. A operação principal é “encontrar as etapas que estão prontas e despachá-las”. Uma etapa está pronta quando ainda está pendente e cada etapa que ela espera atingiu um estado terminal, com sucesso suficiente:

function findReadySteps(plan): PlanStep[] {
  retornar plano.steps.filter((s) => {
    if (s.status! == "pendente") retornar falso;
    const deps = s.wait_for ?? (s.index > 1? [s.index - 1]: []);
    retornar deps.every((d) => isTerminal(plan.step(d).status)); //feito ou ignorado
  });
}

Isso não é executado em um cronômetro, mas em resposta a eventos. Cada vez que uma etapa termina – um subagente retorna, o comandante conclui um turno de síntese – o executor reconcilia: ele registra o resultado da etapa que acabou de ser concluída, depois verifica novamente o que ficou pronto como resultado e o despacha. A orquestração é esse ciclo de reconciliação, que gira continuamente até não restar nenhuma etapa.

O envio passa pelo mesmo chat, não por um canal paralelo

Aqui está uma opção que mantém o sistema honesto: o despacho de uma etapa não usa um canal RPC oculto. Ele publica uma mensagem na mesma conversa em grupo que o usuário está assistindo, do agente principal, @mencionando o subagente. Para o subagente, ser despachado é indistinguível de ser abordado no chat – ele apenas executa seu turno normal. Não há um segundo caminho de execução para manter a sincronia com o primeiro.

Um cessionário pode ser de três tipos, e cada um envia de forma um pouco diferente:

  • Um subagente — o caso comum. O executor resolve o nome do agente para seu id e posta @ do comandante. O subagente o pega e executa um turno completo do agente.
  • O usuário — quando uma etapa realmente precisa de intervenção humana, a etapa se transforma em um formulário e pausa o plano (mais sobre isso abaixo). Nada ocorre até que o usuário responda.
  • O próprio comandante — para síntese ou etapas de decisão ("leia tudo acima e escreva o resumo"). Este é um despertar privado que não publica uma mensagem redundante visível ao usuário; o agente principal simplesmente dá uma volta com o contexto coletado em mãos.

Passando contexto de uma etapa para a próxima

Uma equipe só é útil se o trabalho fluir entre seus membros. O mecanismo é o modelo input. Quando o agente líder escreve uma etapa, a entrada não é uma string congelada — ela pode fazer referência a resultados anteriores, e o executor renderiza essas referências no momento do envio:

// etapa 3.input, conforme escrito pelo agente líder
"Usando as descobertas abaixo, esboce a nota de lançamento.\n\n{{step_1.output_summary}}"

// o que o subagente realmente recebe no momento do despacho
"Usando as descobertas abaixo, esboce a nota de lançamento.\n\n- O mercado está crescendo cerca de 20% em relação ao ano anterior; dois operadores históricos…"

Observe o que é transmitido: output_summary, um breve resumo de cada etapa concluída — não sua transcrição completa. Esta é uma decisão orçamentária e é o mesmo instinto da compactação de contexto do chicote. Se cada etapa downstream herdasse o histórico completo, token por token, de tudo o que foi feito antes dela, o contexto aumentaria e o custo explodiria após apenas alguns saltos. Os resumos mantêm cada transferência barata e mantêm cada subagente focado no que realmente precisa do upstream, em vez de analisar como o upstream chegou lá. A mensagem inicial do usuário e quaisquer anexos também são transportados, portanto, um terceiro passo na cadeia ainda conhece a solicitação original.

Serial por padrão — e por quê

Você pode esperar que, quando vários passos voltam prontos ao mesmo tempo — dois ramos de um diamante, digamos — o orquestrador dispara todos eles em paralelo. Poderia; em vez disso, hoje ele despacha um passo pronto de cada vez, o primeiro por índice, e deixa o restante aguardar pela próxima reconciliação. Liderar uma equipe inteira estritamente individualmente é uma escolha deliberada e conservadora, e vale a pena ser honesto sobre o motivo.

O motivo é a correção sob simultaneidade. Imagine dois subagentes terminando quase ao mesmo tempo. Ambas as conclusões acionam uma reconciliação; ambos os reconciliados leram o plano; ambos veem a mesma etapa downstream ainda em pendente - e ambos a despacham. Agora a mesma etapa é executada duas vezes. Para tornar isso impossível, cada ciclo de leitura-modificação-envio em uma determinada conversa é serializado atrás de um bloqueio por conversa:

// todos os caminhos de mudança de estado para uma conversa são executados em um mutex
planLock(uid, cid).runExclusive(async () => {
  const plano = aguarda readPlan(uid, cid);
  applyOutcomeOfFinishedStep(plano);   //marca como concluído/com falha/ignorado
  aguardar expediçãoReady(plano);          // despacha o próximo passo pronto
});

O bloqueio garante que "registrar o que terminou" e "decidir o que vem a seguir" aconteçam como uma unidade indivisível, de modo que uma etapa posterior nunca possa ser despachada duas vezes. Com esse bloqueio instalado, despachar as etapas uma de cada vez é a coisa mais simples e obviamente correta. A distribuição paralela genuína é uma extensão tratável no topo dessa base - mas a base é um executor serializado e sem corrida, e essa ordem de prioridades (corretar primeiro, rápido depois) é o ponto.

Quando uma etapa dá errado

Executando em uma máquina real em APIs de modelo externo, a falha é rotineira, e o orquestrador a classifica em alguns casos em vez de tratar todos os erros da mesma forma.

Primeiro, ele pergunta se a falha foi meramente transitória — uma queda de conexão, um limite de taxa, um problema. Se for assim, e a etapa ainda não tiver esgotado um pequeno orçamento de novas tentativas, a etapa será silenciosamente revertida para pendente para que a próxima reconciliação a reenvie. (Isso fica acima das novas tentativas em execução do equipamento; o plano só é reenviado depois que as próprias tentativas do agente se esgotam, com um limite máximo para que uma etapa realmente interrompida não possa ser repetida para sempre.)

Se a falha for real, a política on_failure declarada na etapa decidirá o que a equipe fará em seguida:

  • abort_plan — esta etapa foi importante o suficiente para que nada posterior fizesse sentido sem ela. Marque como falha e cascata: cada etapa ainda pendente é marcada como ignorada. O plano termina de forma limpa, em vez de ser construído sobre uma base que falta.
  • continuar — esta etapa era opcional. Marque-o como ignorado e deixe as etapas subsequentes prosseguirem como se simplesmente não produzissem nada.
  • ask_commander (o padrão) — nem abortar cegamente nem continuar cegamente. Marque a falha e desperte o agente principal para ver o que aconteceu e decidir: tentar novamente de forma diferente, contornar o problema ou parar e perguntar ao usuário.

Há um sexto status que vale a pena destacar: bloqueado. Um subagente no meio de sua etapa pode perceber que precisa de algo que somente o usuário pode fornecer e apresentar um formulário ou uma pergunta. A etapa não falha — ela vai para bloqueada e todo o plano é pausado. No momento em que o usuário responde, o executor se reconcilia e a equipe continua exatamente de onde parou. Um plano bloqueado é um plano pausado, não quebrado.

Cada etapa é uma execução completa do agente

Vale a pena fechar o ciclo com o artigo anterior. Quando o orquestrador despacha uma etapa para um subagente, esse subagente não executa nenhuma rotina simplificada - ele executa o loop de chicote completo: seu próprio loop de execução de streaming, suas próprias chamadas de ferramenta, sua própria janela de contexto com compactação, sua própria sessão segura contra falhas. A orquestração fica bem sobre o tempo de execução de agente único; nunca chega dentro dele. O agente principal decide o formato do trabalho e a ordem das transferências; cada subagente, uma vez entregue sua fatia, é um agente completo por si só.

Essas camadas são a razão pela qual os dois artigos são compostos. O equipamento torna um agente confiável para uma tarefa. O orquestrador reúne vários agentes confiáveis ​​em uma equipe que pode assumir uma tarefa muito grande ou muito variada para qualquer um deles.

Algumas decisões importantes

O orquestrador é dono do estado de execução, o modelo é dono da intenção. O modelo propõe o plano; apenas o tempo de execução marca as etapas concluídas, com falha ou ignoradas e apenas em resposta a algo que realmente está acontecendo. Esse limite é o que mantém o plano como um espelho honesto da realidade, em vez da estimativa otimista do modelo.

Envio pela mesma conversa, não por um canal secundário. Uma etapa despachada é apenas uma mensagem do agente principal para um subagente. Um caminho de execução, nada oculto que possa ficar fora de sincronia, e o usuário pode observar a equipe trabalhando no mesmo thread que já está lendo.

Resumos, não transcrições, entre as etapas. Cada transferência traz um breve resumo da saída do upstream. Ele mantém os orçamentos de contexto sãos em cadeias longas e mantém cada subagente focado no que precisa, não em como o anterior chegou lá.

Corrigir antes de paralelo. Um bloqueio por conversa serializa cada ciclo de leitura-modificação-envio e as etapas despacham uma de cada vez. Estritamente serial é a versão obviamente livre de corridas de despacho duplo; a distribuição paralela é uma otimização para colocar em camadas sobre uma base que já está correta.

Concluindo

A camada de orquestração do Orkas não tem nenhum algoritmo exótico em sua essência. Seu valor está em alguns limites mantidos firmemente: um plano que é um gráfico de dependência em vez de um script; estado de execução pertencente ao tempo de execução e não ao modelo; envio que flui pelo mesmo chat que o usuário está assistindo; contexto que se move entre as etapas como resumos; e um executor que coloca a correção à frente da simultaneidade. Cada um é simples por si só. Juntos, eles transformam um único agente confiável em uma equipe que divide o trabalho, repassa o trabalho e se recupera quando algo dá errado.

Se você quiser a camada abaixo desta, leia como um único agente é projetado para funcionar de maneira confiável. Se você deseja a camada que faz com que cada agente melhore com o uso, leia como os agentes Orkas aprendem com seu próprio trabalho. E se você prefere dirigir essa camada em vez de construí-la, o Orkas a entrega como orquestração de agentes de IA open source que roda na sua máquina.

上一篇讲的是怎么让一个 agent 可靠地跑起来:运行循环、工具路由、上下文压缩、可自愈的会话。那一层回答的是「单个 agent 怎么不出岔子地走完一个任务」。这一篇讲它上面的一层——当一个 agent 不够用、一件事得拆给一个团队来做时,会发生什么。

这就是人们常说的多 Agent 编排(multi-agent orchestration):一个掌管对话的主 agent 把请求拆成若干块,每块交给一个专门的子 agent,等该等的那几个跑完再开下一个,把结果往后传,并在某一步失败时把整件事稳在轨道上。Orkas 把这一整套完全跑在用户自己的机器上。下面就拆一下这层编排是怎么搭起来的——代码做了脱敏和泛化,但结构是真实的。

主 Agent 与子 Agent

心智模型是一支指挥链清晰的小团队。一个主 agent(我们叫它 commander)掌管对话和整体上下文。它不亲自干所有活,它的职责是决定什么该做、按什么顺序、由谁来做子 agent 则是专才——各自配着自己的系统提示词、自己被允许用的工具、自己的一套技能。一个子 agent 只擅长其中一小块工作,轮到那一块时才被调起来。

有两点让它不只是个口号。第一,子 agent 和技能是一等单元,不是提示词技巧:子 agent 是一个真实、单独配置的 agent,派遣给它是一次带着独立上下文的真正交接。第二,协调不靠模型的良好意愿——它由一份显式的产物来驱动,而这份产物的真伪由系统、而非模型来保证。这份产物就是计划(plan)。

计划是一张图,不是一段脚本

当主 agent 判断一个请求需要不止一步时,它会写一份计划。这份计划既不是自由的散文,也不是一条线性清单——它是一张小小的依赖图(DAG)。每个节点是一步,每一步带着编排器派遣它所需要的一切:

interface PlanStep {
  index: number;            // 从 1 开始,稳定,永不重新编号
  title: string;            // 给人看的标题,会显示在 UI 上
  assignee: string;         // 由谁执行:"user" | "commander" | 某个子 agent
  input?: string;           // 派遣负载——一个模板(见下文)
  wait_for?: number[];      // 前置步骤的 index;默认是 [index - 1]
  on_failure?: "abort_plan" | "continue" | "ask_commander";

  // --- 运行时状态,归编排器所有,模型不写 ---
  status: "pending" | "in_progress" | "done" | "failed" | "skipped" | "blocked";
  output_summary?: string;  // 这一步产出的简短摘要
  output_files?: string[];  // 这一步产出的文件
  failure_reason?: string;
}

把列表变成图的,正是 wait_for 这个字段。默认一步只等它前面那一步(一条简单的链),但一步可以声明它依赖前面好几步——「写总结」可能同时要等「调研市场」和「摸排竞品」。这是一个菱形,不是一条直线,编排器把它当图来处理。

谁说了算:编排器,不是模型

再看一眼那个结构体里的切分。模型在第一次写计划时填的是意图——标题、执行者、输入、依赖关系。但所有关于执行状态的东西——statusoutput_summaryfailure_reason——只归编排器所有。模型只提一次计划,它永远没机会把自己的步骤标成「done」。

这个分离是刻意的,也是整个设计里最重要的一个决定。语言模型完全可能在第 3 步明明报错时,乐呵呵地宣布「第 3 步完成」,也可能在一段长对话里跑到一半就忘了还有哪些步骤没做。如果状态活在模型脑子里,计划就会和现实越漂越远。把状态做成一份只有执行器才写、而且只在某一步真正结束时才写的结构化产物,计划就始终是「真实发生了什么」的准确镜像。模型决定工作的形状,运行时决定关于进度的事实。

派遣那些已经就绪的步骤

计划一旦存在,一个小引擎——执行器(executor)——就推着它往前走。核心动作是「找出就绪的步骤并派遣它们」。一步在它仍是 pending、且它所等待的每一步都到达了某个终态、且足够成功时,才算就绪

function findReadySteps(plan): PlanStep[] {
  return plan.steps.filter((s) => {
    if (s.status !== "pending") return false;
    const deps = s.wait_for ?? (s.index > 1 ? [s.index - 1] : []);
    return deps.every((d) => isTerminal(plan.step(d).status)); // done 或 skipped
  });
}

它不是靠定时器跑的,而是由事件驱动。每当一步结束——一个子 agent 返回了、commander 收尾了一轮综合——执行器就做一次「对账(reconcile)」:先记下刚结束那一步的结果,再重新扫一遍看哪些因此变就绪了,然后派遣出去。编排就是这个对账循环,一轮接一轮地翻动,直到没有步骤剩下。

派遣走的是同一个对话,不是旁路通道

有个选择让系统始终诚实:派遣一步用的不是一条隐藏的 RPC 通道。它是往用户正盯着看的那个群组对话里发一条消息,以主 agent 的名义、@ 那个子 agent。在子 agent 看来,「被派遣」和「在聊天里被点名」没有区别——它就是照常跑一轮。不存在第二条要和第一条保持同步的执行路径。

执行者(assignee)可以是三种之一,每种派遣方式略有不同:

  • 子 agent——最常见的情况。执行器把 agent 名字解析成 id,以 commander 的名义发出 @<agent> <渲染后的输入>。子 agent 接住,跑一轮完整的 agent turn。
  • 用户——当某一步确实需要人来提供信息时,这一步会变成一张表单并暂停计划(下文细说)。在用户回答之前,下游什么都不会动。
  • commander 自己——用于综合或决策步骤(「把上面的都读一遍,写出总结」)。这是一次私密唤醒,不会再发一条多余的、用户可见的消息;主 agent 直接带着收集到的上下文跑一轮。

把上下文从一步传到下一步

一个团队只有当工作能在成员之间流动时才有用。这个机制就是 input 模板。主 agent 写一步时,输入并不是一个冻死的字符串——它可以引用前面的结果,执行器在派遣那一刻把这些引用渲染出来:

// 第 3 步的 input,由主 agent 写下
"基于下面的调研结论,起草发布说明。\n\n{{step_1.output_summary}}"

// 派遣时子 agent 实际收到的
"基于下面的调研结论,起草发布说明。\n\n- 市场同比增长约 20%;两家在位者……"

注意往后传的是什么:output_summary,每一步完成后的一份简短摘要——不是它的完整对话记录。这是一个预算上的决定,背后是和 harness 上下文压缩同一个直觉。如果每个下游步骤都继承前面所有内容逐字逐句的完整历史,上下文会迅速膨胀,几跳之后成本就爆了。摘要让每一次交接都很便宜,也让每个子 agent 专注在它真正需要从上游拿到的东西上,而不是去趟上游「是怎么走到这一步」的浑水。最初的用户消息和任何附件也会一路带着走,所以链条下游第三跳的步骤,依然知道最初的诉求是什么。

默认串行——以及为什么

你可能以为,当好几步同时变就绪时——比如菱形的两条分支——编排器会把它们一起并行发出去。它本可以;但今天它的做法是一次只派遣一个就绪步骤,按 index 取最早的那个,其余的等下一次对账。让一整支团队严格地一次只跑一个,是一个刻意的、保守的选择,值得老实讲清楚为什么。

原因是并发下的正确性。设想两个子 agent 几乎在同一瞬间结束。两个结束都会触发对账;两次对账都会读计划;两次都看到同一个下游步骤还停在 pending——于是两次都把它派了出去。现在同一步跑了两遍。为了让这件事根本不可能发生,对某个对话的每一次「读取—修改—派遣」循环,都被串行化在一把以对话为粒度的锁后面:

// 一个对话里所有会改状态的路径,都在同一把 mutex 下跑
planLock(uid, cid).runExclusive(async () => {
  const plan = await readPlan(uid, cid);
  applyOutcomeOfFinishedStep(plan);   // 标记 done / failed / skipped
  await dispatchReady(plan);          // 派遣下一个就绪步骤
});

这把锁保证「记录什么结束了」和「决定接下来做什么」作为一个不可分割的整体发生,于是下游步骤永远不会被派两遍。有了这把锁,一次派一个就是那种「一眼就看得出是对的」的最简做法。真正的并行扇出(fan-out)是在这个地基之上一个完全可行的扩展——但地基是一个串行化、无竞态的执行器,而这个优先级排序(先对,再快)正是关键所在。

当某一步出错时

跑在真实机器上、对接外部模型 API,失败是家常便饭,编排器把它分成几类来处理,而不是把所有错误一视同仁。

它先问这次失败是不是只是瞬时的——连接断了、被限流、抖了一下。如果是,且这一步还没烧光那一点点重试预算,就悄悄把它回滚到 pending,让下一次对账重新派遣。(这一层在 harness 自己的单轮重试之上;只有当 agent 自己的尝试都用尽后,计划才会重新派遣,而且有硬上限,免得一个真坏掉的步骤无限打转。)

如果是真失败,这一步声明的 on_failure 策略决定团队接下来怎么走:

  • abort_plan——这一步重要到没了它下游全都没意义。把它标记为失败,然后级联:每一个还 pending 的步骤都标成 skipped。计划干净地停下,而不是在一个缺失的地基上继续盖。
  • continue——这一步是可选的。把它标成 skipped,让下游步骤当它什么也没产出,照常往下走。
  • ask_commander(默认)——既不盲目中止,也不盲目继续。把它标记为失败,唤醒主 agent 来看看发生了什么、再决定——换个方式重试、绕过它、还是停下来问用户。

还有第六种状态值得单独点出来:blocked。一个子 agent 跑到一半,可能意识到它需要只有用户才能给的东西,于是抛出一张表单或一个问题。这一步不算失败——它进入 blocked,整份计划暂停。用户一回答,执行器就对账,团队从刚才停下的地方原样接着干。一份 blocked 的计划是暂停的计划,不是坏掉的计划。

每一步都是一次完整的 agent 运行

值得把环路接回上一篇。当编排器把一步派给一个子 agent 时,这个子 agent 跑的不是某种精简版例程——它跑的是完整的 harness 循环:自己的流式运行循环、自己的工具调用、自己带压缩的上下文窗口、自己可自愈的会话。编排干净地坐在单 agent 运行时之上,从不伸手进它内部。主 agent 决定工作的形状和交接的顺序;每个子 agent 一旦接过自己那一块,本身就是一个完整的 agent。

正是这种分层,让两篇文章能拼起来。harness 让一个 agent 在一个任务上值得信任。编排器把若干个值得信任的 agent 拼成一支团队,去啃一个对任何单个 agent 都太大、或太杂的任务。

几个关键的决定

执行状态归编排器,意图归模型。 模型提计划;只有运行时才把步骤标成 done、failed 或 skipped,而且只在真有事情发生时才标。就这一条边界,让计划是现实的诚实镜像,而不是模型乐观的猜测。

派遣走同一个对话,不走旁路。 被派遣的一步只是主 agent 发给子 agent 的一条消息。一条执行路径,没有藏起来会跑偏的东西,用户还能在自己正读的那个会话里看着团队干活。

步骤之间传摘要,不传全文。 每次交接带的是上游产出的一份简短摘要。它让长链条上的上下文预算保持理智,也让每个子 agent 专注于它需要的东西,而不是前一个是怎么得到的。

先对,再并行。 一把以对话为粒度的锁把每一次「读取—修改—派遣」循环串行化,步骤一次派一个。严格串行是那个「一眼看得出没有重复派遣竞态」的版本;并行扇出是叠在一个已经正确的地基上的优化。

小结

Orkas 的编排层核心没有什么奇异算法。它的价值在于守住了几条边界:计划是依赖图而不是脚本;执行状态归运行时而不归模型;派遣流经用户正看着的同一个对话;上下文以摘要的形式在步骤间流动;执行器把正确性摆在并发之前。每一条单看都简单。合起来,它们把一个可靠的单 agent,变成一支会分工、会把工作往后传、在某一块出错时还能恢复的团队。

想看这一层下面那一层,去读一个 agent 是怎么被工程化得可靠运行的。想看让每个 agent 越用越好用的那一层,去读 Orkas agent 是怎么从自己的工作里学习的。如果你更想直接指挥这一层,而不是自己造一套,Orkas 把它做成了跑在你自己机器上的开源 AI agent 编排

前回の記事では、ひとつの agent を信頼できるようにする層を扱いました。run loop、tool routing、context compaction、クラッシュしても壊れない session です。あの層が答えるのは「単体の agent がどうタスクを最後まで走り切るか」でした。今回はその上の層、ひとつの agent では足りず、仕事をチームへ分けるときに何が起きるかを見ます。

これが一般に multi-agent orchestration と呼ばれるものです。会話を所有する主 agent が依頼を分解し、それぞれの断片を専門のサブ agent に渡し、必要な前工程が終わるまで待ち、結果を後段へ渡し、どこかが失敗したときも全体を軌道に戻します。Orkas では、この編成層もユーザー自身のマシン上で動きます。

主 Agent とサブ Agent

心の中のモデルは、指揮系統のはっきりした小さなチームです。主 agent、Orkas では commander と呼ぶものが、会話と全体文脈を持ちます。主 agent は全部を自分でやるのではなく、何を、どの順番で、誰がやるべきかを決めます。サブ agent は専門家です。それぞれが独自の system prompt、許可された tools、skills を持ち、特定の作業片を担当します。

ここで重要なのは二点です。第一に、サブ agent と skills はプロンプト上の小技ではなく、一級の単位です。サブ agent は個別に設定された実体であり、そこへの dispatch は独立した文脈を持つ本当の引き渡しです。第二に、調整をモデルの善意に任せません。システムが正しさを保つ明示的な成果物、つまり plan が中心になります。

計画はスクリプトではなくグラフ

主 agent が依頼に複数ステップが必要だと判断すると、plan を書きます。これは自由文でも直線的な checklist でもなく、小さな依存グラフ、つまり DAG です。各 node は step で、dispatch に必要な情報を持ちます。

interface PlanStep {
  index: number;            // 1 始まりで安定し、再番号付けしない
  title: string;            // UI に出す人間向けのタイトル
  assignee: string;         // "user" | "commander" | サブ agent
  input?: string;           // dispatch payload、テンプレート
  wait_for?: number[];      // 依存する前段 step。既定は [index - 1]
  on_failure?: "abort_plan" | "continue" | "ask_commander";

  status: "pending" | "in_progress" | "done" | "failed" | "skipped" | "blocked";
  output_summary?: string;
  output_files?: string[];
  failure_reason?: string;
}

wait_for が list を graph に変えます。通常は前の step を待つだけですが、「市場調査」と「競合調査」の両方を待ってから「要約」を書く、といった菱形の依存も表せます。

真実を持つのはモデルではなく Orchestrator

モデルが最初に埋めるのは意図です。title、assignee、input、依存関係です。一方で、statusoutput_summaryfailure_reason のような実行状態は orchestrator だけが書きます。モデルは計画を提案しますが、自分の step を勝手に done にできません。

この境界が設計上もっとも大切です。言語モデルは、失敗した step を平気で「完了」と言ってしまうことがあります。長い会話の途中で未完了の step を見失うこともあります。状態をモデルの頭の中に置くと、計画は現実からずれていきます。実行状態を runtime が持つ構造化 artifact にすると、plan は実際に起きたことの鏡であり続けます。

Ready な step を dispatch する

plan ができると、executor が進行を管理します。中核操作は「ready な step を見つけて dispatch する」ことです。step がまだ pending で、依存するすべての step が十分成功した終端状態に達しているとき、その step は ready になります。

function findReadySteps(plan): PlanStep[] {
  return plan.steps.filter((s) => {
    if (s.status !== "pending") return false;
    const deps = s.wait_for ?? (s.index > 1 ? [s.index - 1] : []);
    return deps.every((d) => isTerminal(plan.step(d).status)); // done or skipped
  });
}

これは timer で走るのではなく、event に反応します。サブ agent が返ってきた、commander がまとめを終えた、というたびに executor が reconcile します。終わった step の結果を記録し、それによって新しく ready になった step を探し、次を dispatch します。

Dispatch は同じ会話を通る

Orkas では step の dispatch に隠れた RPC を使いません。ユーザーが見ている同じ group conversation に、主 agent からサブ agent への @mention として投稿します。サブ agent から見ると、dispatch されたことは chat で呼ばれたことと同じです。実行経路が二つに分かれないので、片方だけがずれる心配がありません。

assignee は三種類です。サブ agent の場合は commander から @<agent> <rendered input> が送られます。user の場合は form や質問として plan が止まり、回答を待ちます。commander 自身の場合は、収集済み文脈を持って内部的に一 turn 走り、まとめや判断を行います。

Step 間で文脈を渡す

チームが意味を持つのは、仕事がメンバー間で流れるからです。その仕組みが input テンプレートです。主 agent が step を書くとき、input は固定文字列ではありません。前段の結果を参照し、dispatch 時に executor が展開します。

// step 3.input
"以下の調査結果を使って、ローンチ告知文を下書きしてください。\n\n{{step_1.output_summary}}"

// dispatch 時にサブ agent が受け取るもの
"以下の調査結果を使って、ローンチ告知文を下書きしてください。\n\n- 市場は前年比約20%で成長、主要競合が2社..."

渡すのは完全な transcript ではなく、output_summary という短い要約です。前段の全履歴を後段へ渡すと context はすぐ膨らみ、コストも増えます。要約なら、必要な情報だけを安く渡せます。最初のユーザー依頼と添付も運ばれるので、数 step 後の agent も元の目的を失いません。

既定は直列、その理由

複数の branch が同時に ready になったら、すべて並列に走らせたくなります。Orkas は現時点では、ready な step を index 順にひとつずつ dispatch します。保守的ですが、理由は concurrency 下の正しさです。

二つのサブ agent がほぼ同時に終わると、二つの reconcile が同じ plan を読み、同じ下流 step が pending だと見て、同じ step を二重 dispatch してしまう可能性があります。これを防ぐため、ひとつの conversation に対する read-modify-dispatch は per-conversation lock の下で直列化します。

planLock(uid, cid).runExclusive(async () => {
  const plan = await readPlan(uid, cid);
  applyOutcomeOfFinishedStep(plan);
  await dispatchReady(plan);
});

この lock により、「何が終わったかを記録する」と「次を決める」が不可分になります。並列 fan-out はこの上に乗せられる最適化ですが、土台はまず race-free であるべきです。

Step が失敗したら

外部モデル API と実マシン上で動く以上、失敗は普通に起こります。orchestrator はまず transient error かどうかを見ます。接続断、rate limit、短い障害なら、retry budget の範囲で step を pending に戻し、次の reconcile で再 dispatch します。

本当の失敗なら、step の on_failure が方針を決めます。

  • abort_plan は、下流が成立しない重要 step の失敗です。残りの pending step を skipped にして、計画をきれいに止めます。
  • continue は任意 step の失敗です。skipped として扱い、下流を進めます。
  • ask_commander は既定です。主 agent を起こして、別ルートで retry するか、迂回するか、ユーザーに聞くかを判断させます。

blocked という状態もあります。サブ agent が途中でユーザーの入力を必要とした場合、step は失敗ではなく blocked になります。plan は一時停止し、ユーザーの回答が来たら続きから再開します。

各 step は完全な agent run

orchestrator がサブ agent へ step を渡しても、そのサブ agent は簡易版の routine を走るわけではありません。完全な harness loop を使い、自分の streaming run loop、tool calls、context compaction、crash-safe session を持って動きます。編成層は単体 agent runtime の上に座り、内部には手を入れません。

だから二つの記事はつながります。harness はひとつの agent を信頼できるようにし、orchestrator は信頼できる agent たちをチームにします。

大切だった設計判断

実行状態は orchestrator、意図はモデル。 この境界が、plan をモデルの楽観ではなく現実の鏡にします。

dispatch は同じ会話を通す。 隠れた経路がないので、ユーザーもチームの動きを同じ thread で見られます。

step 間は transcript ではなく要約を渡す。 長い chain でも context budget を保ち、各サブ agent が必要な情報に集中できます。

並列化より先に正しさ。 per-conversation lock と一つずつの dispatch は地味ですが、二重 dispatch race を避ける最も分かりやすい土台です。

まとめ

Orkas の orchestration layer の中心に奇抜なアルゴリズムはありません。価値は、いくつかの境界をきちんと守ることにあります。plan は script ではなく dependency graph、実行状態はモデルではなく runtime が持つ、dispatch はユーザーが見ている同じ chat を通る、文脈は要約として流れる、executor は concurrency より correctness を優先する。これらが合わさって、ひとつの信頼できる agent は、分担し、結果を渡し、失敗から戻れるチームになります。

この下の層を知りたい場合は、単体 agent をどう信頼できる実行環境にしているかを読んでください。各 agent が使うほど良くなる仕組みは、Orkas agent が自分の仕事からどう学ぶかで説明しています。この層を自分で作るより指揮したい場合、Orkas は自分のマシンで動くオープンソースの AI エージェント編成として提供しています。