Projetando um chicote de agente pronto para produção com persistência e pontos de verificação


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.

  1. 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.
  2. Ponto de verificação em cada transição significativa, para que a recuperação continue a partir do último ponto positivo.
  3. 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.
  4. Evite que dois trabalhadores realizem a mesma corrida ao mesmo tempo.
  5. 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.



Source link