Certa vez, uma execução de agente pela qual eu era responsável deu quatorze etapas em uma tarefa e depois desapareceu, porque uma implantação de rotina reagendou o pod em que estava sendo executado. Nenhum erro nos logs que valha esse nome. O processo de orquestração desapareceu no meio da execução, levou consigo o estado da memória e deixou o usuário observando uma tarefa que nunca seria concluída. O modelo não fez nada de errado. Nós simplesmente não tínhamos construído nada para correr quando o chão se movia sob ele.
Essa lacuna entre “o modelo funciona” e “o sistema continua funcionando” é o aproveitamento do agente, e é onde passei boa parte dos últimos dois anos, construindo o tempo de execução que transporta nossos fluxos de trabalho de agente através de um conjunto de pods, em vez de um único processo.
Antes dos agentes, passei anos em armazenamento e sistemas distribuídos: replicação que precisava garantir que uma gravação sobrevivesse após o momento em que fosse reconhecida, agendadores que entregavam o trabalho aos nós sem duplicá-lo. Os tempos de execução dos agentes rimam com o trabalho antigo mais do que as pessoas esperam. Uma corrida que perde seu progresso devido ao despejo de um casulo é um problema de durabilidade, e durabilidade é um terreno bem trilhado. O que se segue é a camada de persistência e pontos de verificação que permite que um agente sobreviva à interrupção, com código que você pode executar.
Tudo aqui é executado na biblioteca padrão do Python. Eu usei o SQLite para que ele seja executado sem configuração, embora o design seja transferido para o Postgres, que é o que buscamos quando o estado de uma execução precisa sobreviver a qualquer processo único.
O que o pronto para produção realmente exige
Aqui está a pequena lista que o arnês precisa acertar. O resto do artigo é uma implementação dele.
- Trate cada execução como um estado durável em um armazenamento, e não como objetos na memória que morrem com o processo.
- Ponto de verificação em cada transição significativa, para que a recuperação continue a partir do último ponto positivo.
- Torne as chamadas de ferramentas seguras para serem executadas mais de uma vez, porque depois de uma falha, muitas vezes você não consegue saber se a última foi executada.
- Evite que dois trabalhadores realizem a mesma corrida ao mesmo tempo.
- Registre o suficiente em cada etapa para depurar falhas que ninguém estava observando.
Eu teria escrito uma lista de verificação quase idêntica para um agendador de tarefas. A única diferença aqui é que o trabalho que está sendo coordenado é um loop LLM em vez de um bloco de dados.
Modelando uma execução como estado durável
Pare de tratar uma execução como uma chamada de função. Trate-o como um registro com um ID, uma posição e um histórico que você pode recarregar.
píton
@dataclass
Etapa da aula:
índice: int
tipo: str # “modelo” ou “ferramenta”
pedido: ditado
resposta: ditado | Nenhum = Nenhum
status: StepStatus = StepStatus . PENDENTE
@dataclass
corrida de classe:
run_id:str
objetivo: str
etapas: lista = campo (default_factory = lista)
cursor: int = 0 # índice do próximo passo a ser executado
status: str = “em execução” # em execução | concluído | fracassado
versão: int = 0 # incrementado a cada gravação durável
O cursor é o que faz a retomada funcionar. Ao recarregar, o chicote lê e sabe qual passo é o próximo, sem nada para repetir. O campo version evita que dois processos se sobrescrevam, e voltarei a ele na loja. A execução completa serializa em um documento JSON codificado por run_id. Você pode normalizar as etapas em sua própria tabela, e talvez seja necessário passar por alguns milhares de etapas, mas um único documento é lido em uma consulta no caminho de recuperação, que é o caminho que você mais deseja que seja rápido e simples.
Decidindo aonde pertencem os pontos de verificação
Um ponto de verificação é uma gravação durável do estado da execução. A frequência com que você faz uma é uma negociação com custo, nada mais.
| Onde você faz o checkpoint | Custo enquanto saudável | Trabalho perdido em um acidente | Quando cabe |
| Uma vez, no final | Mais baixo | Toda a corrida | Curtas tiragens baratas para refazer |
| Depois de cada passo | Moderado | No máximo um passo | O padrão para a maioria dos agentes |
| Em torno de cada efeito colateral | Mais alto | Nada que você não possa reconstruir | Corre que movimenta dinheiro ou envia mensagens |
Um agente de pesquisa somente leitura é um bom ponto de verificação após cada etapa. Para qualquer coisa que atue no mundo exterior, escrevo duas vezes em torno da etapa de atuação: uma vez para dizer que estou prestes a fazer isso, uma vez para registrar o que aconteceu. A primeira gravação parece inútil até que um pod morra no meio da chamada. Quando o chicote reinicia e encontra uma etapa em RUNNING, esse status salvo é a única razão pela qual ele sabe que a chamada já pode ter sido disparada.
Uma camada de persistência que sobrevive à simultaneidade
O método que carrega o peso é o checkpoint. Ele faz uma comparação e troca de versão, portanto, uma gravação só chega se ninguém tocou na linha desde que a lemos.
píton
ponto de verificação def (self, executar: Executar) -> Nenhum:
cur = eu mesmo. conexão. executar(
“UPDATE executa SET data =?, versão = versão + 1”
“ONDE run_id = ? E versão = ?”,
(_serialize (executar), executar . run_id, executar . versão),
)
auto . conexão. comprometer-se()
se cur. contagem de linhas == 0:
aumentar StaleWriteError(
f”run {run . run_id} movido para a versão anterior {run . version}”
)
correr . versão += 1
A versão AND = ? cláusula é toda a ideia. Se dois trabalhadores carregarem a versão 7 e tentarem gravar, um atualizará uma linha ainda em 7 e o outro obterá rowcount == 0 mais um StaleWriteError, que informa que não é mais o proprietário desta execução e deve recuar. Este é o mesmo padrão de proteção que os agendadores usam para impedir que dois trabalhadores reivindiquem o mesmo trabalho, uma comparação e troca de uma versão para que apenas um escritor ganhe. São máquinas antigas de sistemas distribuídos que apontam para um novo problema. A carga útil é diferente agora; o mecanismo é o mesmo.
Um loop de execução que retoma em vez de reiniciar
O loop carrega uma execução, encontra a etapa atual e verifica pontos em ambos os lados do trabalho. Morra em qualquer linha e uma chamada posterior pode carregar o mesmo run_id e continuar.
píton
etapa . status = StepStatus. CORRENDO
store.checkpoint(run) # “prestes a executar esta etapa”
tentar:
se passo. tipo == “modelo”:
etapa . resposta = modelo. chamar(run.goal, run.steps(:step.index))
outro:
etapa . resposta = ferramentas. chamar(
etapa . solicitar,
idempotency_key=f”{run. run_id}:{step. index}”,
)
etapa . status = StepStatus. FEITO
exceto ToolError:
etapa . status = StepStatus. FRACASSADO
correr . status = “falhou”
loja . ponto de verificação (execução) # resultado (ou falha) registrado
Siga um pod que morre logo após o checkpoint RUNNING, mas antes do resultado. Ao reiniciar, o chicote carrega a execução, vê uma etapa marcada como RUNNING e não consegue identificar pelo registro se a chamada da ferramenta foi encerrada. Uma etapa do modelo pode ser executada novamente com segurança. É seguro executar novamente uma etapa da ferramenta somente se a ferramenta tiver sido criada para ela, que é a próxima seção.
A chave de idempotência é run_id mais índice de etapa. Por ser determinística, a nova tentativa após uma falha gera a mesma chave da primeira tentativa, e é isso que permite que a camada de ferramentas identifique a repetição.
Eu não queria apenas afirmar que isso funciona, então executei o chicote com um travamento forçado em uma etapa de ferramenta e depois retomei. A verdadeira saída:
texto
=== tentativa 1 (travará na etapa 1) ===
(travamento) pod morreu durante a etapa 1
estado após falha: ((0, ‘model’, ‘done’), (1, ‘tool’, ‘running’)) | versão: 4
=== tentativa 2 (currículo) ===
estado final: ((0, ‘modelo’, ‘pronto’), (1, ‘ferramenta’, ‘pronto’),
(2, ‘modelo’, ‘pronto’), (3, ‘ferramenta’, ‘pronto’))
status de execução: concluído | Efeitos colaterais da ferramenta REAL executados: 2
A execução exigia dois efeitos colaterais da ferramenta e executou exatamente dois, embora a etapa 1 tenha sido tentada em dois processos separados. A recuperação foi retomada a partir do estado de execução durável em vez de recomeçar.
O chicote não pode prometer que uma ferramenta funcione exatamente uma vez. O que os sistemas distribuídos oferecem é entrega pelo menos uma vez mais idempotência, e esses dois juntos proporcionam o mesmo resultado que um chamador veria exatamente uma vez. Portanto, o trabalho da camada de ferramentas é tornar a repetição inofensiva.
píton
def call(self, request: dict, idempotency_key: str) -> dict:
linha = eu mesmo. conexão. executar(
“SELECIONE resultado FROM tool_results WHERE key =?”, (idempotency_key,)
). buscar()
se a linha não for Nenhuma:
retornar json. loads(row(0)) # chave já executada; reutilizar seu resultado
resultado = eu mesmo. _invoke(request) # a chamada real e com efeitos colaterais
self.conn.execute(
“INSERT INTO tool_results (chave, resultado) VALORES (?, ?)”,
(idempotency_key, json. dumps(resultado)),
)
auto . conexão. comprometer-se()
resultado de retorno
A verificação em processo é um piso. A versão mais forte envia a mesma chave para o serviço externo quando aceita uma; APIs de pagamento são o caso familiar, onde uma chave de idempotência significa que o próprio provedor não cobrará duas vezes por ela. Quando o serviço downstream desduplica sua chave, você fica protegido mesmo no caso desagradável em que a primeira chamada foi bem-sucedida remotamente, mas seu processo morreu antes de gravá-la. Quando um serviço não oferece nada, recorro a qualquer chave natural que a operação já possua, geralmente um upsert em (run_id, step_index), para que uma repetição sobrescreva em vez de duplicar.
Alguns efeitos colaterais resistem à idempotência, e é melhor dizê-lo do que fingir que o padrão é livre. E-mail é aquele que todos encontram. A resposta prática é uma tabela de desduplicação que o mailer verifica antes de enviar, o que reduz a janela de envio duplo sem fechá-la totalmente. Para muitos sistemas, essa janela limitada é o alvo honesto.
O que eu assisto quando está em execução
Operar isso revelou algumas coisas que o design não revelou. Registre o run_id, o índice da etapa e a versão em cada transição. Esse histórico de versão é o mais próximo que você chega de um gravador de vôo, e eu desembaracei alguns incidentes genuinamente estranhos apenas lendo a série de alterações de versão para um único run_id.
O número com o qual mais me importei não foi a latência ou o gasto de token. Foi quantas etapas retornaram como RUNNING no tempo de recuperação, já que isso é uma leitura direta da frequência com que você morre no meio de um efeito colateral e depende da idempotência para cobri-lo. Quando essa contagem aumenta, geralmente é uma dependência upstream que fica instável sob carga, e essa métrica tende a se mover antes da taxa de erro downstream, o que a torna um sinal inicial útil.
Coloque um TTL nas etapas RUNNING e alerte-o. Uma etapa travada em RUNNING muito além de qualquer tempo de execução sensato é quase sempre um trabalhador que agarrou a corrida e depois morreu sem desistir, e esse alerta também detecta os casos extremos de leasing que você não pode reproduzir facilmente em um teste.
Onde fazer isso a seguir
Este é um arnês para um único trabalhador com uma coluna durável. A coluna de versão já está fazendo uma cerca leve, então o próximo passo óbvio é o leasing real: um trabalhador reivindica uma execução escrevendo seu próprio ID e uma expiração do arrendamento na linha, renova enquanto funciona e outros trabalhadores deixam de lado qualquer execução cujo arrendamento ainda esteja ativo. Isso transforma o protetor de versão em uma camada de coordenação e permite que um conjunto de trabalhadores extraia de uma fila compartilhada.
O modelo de persistência também fica longe da recuperação. Uma vez que cada execução é um registro consultável com seu histórico completo de etapas, você pode reproduzir execuções antigas em um novo modelo e comparar, o que já é uma grande parte de um equipamento de avaliação. Vale a pena seguir esse caminho se a confiabilidade não for a única coisa que você deseja do estado que já está armazenando.
De tudo o que foi dito acima, a ideia mais barata é a que eu lhe daria primeiro. Escreva o ponto de verificação que diz “prestes a executar” antes de executar. É uma viagem de ida e volta extra, parece redundante na revisão e é a linha que transforma um estado pós-travamento ambíguo em um estado do qual você pode se recuperar.
Nandish Nanjappa é engenheiro de software da equipe da Attentive, onde mantém fluxos de trabalho de IA de agente funcionando de maneira confiável em uma frota de pods. Ele chegou à IA depois de mais de uma década trabalhando em sistemas distribuídos e de armazenamento e descobriu que a maioria dos problemas difíceis rimam. Ele escreve sobre o trabalho nada glamoroso de infraestrutura que torna os agentes de IA confiáveis.




