Deixando um Agente de IA Escolher Minhas Publicações Relacionadas

Este arquivo foi traduzido automaticamente por IA, erros podem ocorrer
Eu continuo estendendo a pipeline do meu blog com pequenas automações. Eu corrigir texto das minhas publicações com o Amazon Nova, e eu traduzo-as automaticamente, e cada etapa é executada sem servidor e acionada por eventos. Desta vez, eu fui atrás de algo que tenho pensado há muito tempo.
Eu escrevo uma publicação e alguém a encontra, lê até o fim e depois sai. Essa última parte é o que tenho pensado. Um leitor que acabou de terminar uma publicação sobre Lambda provavelmente está interessado em ler minhas outras publicações sobre serverless. Eu não fiz nada com esse momento, nenhum "leia isto a seguir". Nada. A leitura terminou exatamente no ponto onde deveria ter continuado.
Então eu construí uma pipeline que corrige exatamente isso, um agente de IA que lê cada nova publicação, pesquisa todas as minhas publicações do blog por significado e não por palavras-chave, depois escolhe três publicações relacionadas e explica cada escolha em uma frase para mim. A configuração é toda sem servidor e toda acionada por eventos, o que você esperava? Eu combinei Lambda Durable Functions, Strands Agents, Bedrock AgentCore Gateway e MongoDB Atlas Vector Search em uma única pipeline.
Todo o código para esta publicação pode ser encontrado no handbook serverless como de costume
Por que não usar apenas tags?
Meu blog é HTML estático e um caminho fácil seria usar tags, como muitos outros sites estáticos. Eu fiz um teste e simplesmente não foi bom o suficiente. Eu uso tags como serverless que são bastante amplas, mas também tenho algumas tags mais estreitas como lambda. Mas só porque uma publicação está marcada com serverless ou lambda não significa que não possa compartilhar algo com outras publicações. Tags respondem a "com quais palavras eu rotulei isto?", não "sobre o que esta publicação realmente é?"
O próximo passo seria usar a similaridade de conteúdo. Aqui eu posso criar embeddings vetoriais para cada publicação e retornar os três vizinhos mais próximos, pronto! Bem, sim, este é o backbone da pipeline, mas um topo três vizinhos mais próximos brutos frequentemente produz publicações quase duplicadas cobrindo exatamente o mesmo tópico. O correspondência pura de vizinhos mais próximos pode me dizer que duas publicações são semelhantes, mas não me diz se um leitor que terminou uma obteria algo da outra.
Então eu estendi o design, e o núcleo de tudo é que a busca vetorial recupera dados, e o agente de IA decide qual se relaciona melhor. Eu começo estreitando o número de publicações usando busca vetorial, então um agente de IA, desempenhando o papel do meu editor, lê as publicações e escolhe as três que acha que os leitores gostariam mais. Ele cria uma justificativa de uma frase para por que escolheu aquela publicação. Dessa forma, eu posso revisar e refinar o agente de IA ao longo do tempo.
Visão geral da arquitetura
Quando uma publicação é publicada, a nova pipeline do blog envia um evento PostPublished para um barramento EventBridge.
{
"Source": "....",
"DetailType": "PostPublished",
"Detail": {
"slug": "lambda-durable-functions-101",
"language": "en",
"branch": "main",
"commit_sha": "f8a2c1d3..."
}
}Isso agora invocará uma Lambda Durable Function, que coordenará todo o fluxo. Como a coordenação é principalmente etapas de código, decidi experimentar Durable Functions em vez de Step Functions, que tem sido meu ponto de referência normal no passado. Com Durable Functions eu ganho mais uma ferramenta na minha caixa de ferramentas.

A pipeline se parece assim: ela busca o markdown da publicação no GitHub, chama o Bedrock para criar um embedding usando o Amazon Titan Text Embeddings V2 e insere-o em uma coleção do MongoDB Atlas. Em seguida, o Agente de IA é chamado para escolher as três publicações relacionadas, começando com uma busca vetorial no MongoDB Atlas usando uma ferramenta MCP hospedada no AgentCore Gateway. Então as referências das publicações relacionadas são adicionadas ao DSQL, que faz parte do meu sistema CMS construído em casa, e finalmente atualiza o frontmatter da publicação no GitHub e abre um PR.
Eu também precisei garantir que os embeddings fossem criados para todas as publicações atuais; caso contrário, não funcionaria para novas publicações. Então eu criei uma pequena configuração de preenchimento único.

Isso é muitas peças em movimento para uma função. Vou voltar por que isso funciona.
Do Markdown para embeddings
Toda a pipeline depende de uma transformação, transformando a publicação do blog em embeddings para que possamos encontrar publicações semelhantes programaticamente.
Um modelo de embedding mapeia texto para um vetor fixo de floats. No meu caso, 1024 floats do Titan Text Embeddings v2 do Bedrock. O modelo foi treinado para que textos sobre os mesmos conceitos fiquem próximos nesse espaço de 1024 dimensões, mesmo quando não compartilham vocabulário. Minha publicação sobre Durable Functions e minha publicação sobre Step Functions acabam como vizinhos porque ambas são sobre orquestrar workflows no AWS.

O que entra no embedding importa muito, assim como o modelo. Eu não embeddo o markdown bruto inteiro; primeiro executo uma pequena etapa de normalização que remove o frontmatter YAML, remove todos os exemplos de código e imagens, e finalmente crio uma nova estrutura que pode ser alimentada ao modelo.
TITLE: Lambda Durable Functions 101
TAGS: aws, lambda, serverless
SUMMARY: Um guia prático de construção de um workflow durável no AWS Lambda.
BODY: O corpo limpoEntão por que eu removo todo o código? Bem, blocos de código são apenas "ruído". Duas publicações sobre o mesmo tópico podem ter exemplos de código completamente diferentes, então se eu incluísse o código, essas publicações agora acabariam mais distantes no espaço vetorial. Código não diz nada sobre similaridade, então eu incluo apenas o conteúdo real do blog.
A chamada ao Bedrock para criar o embedding é bastante pequena.
_MODEL_ID = "amazon.titan-embed-text-v2:0"
def embed_text(text: str, dimensions: int = 1024) -> list[float]:
response = _bedrock_client().invoke_model(
modelId=_MODEL_ID,
contentType="application/json",
accept="application/json",
body=json.dumps({
"inputText": text,
"dimensions": dimensions,
"normalize": True,
}),
)
payload = json.loads(response["body"].read())
return [float(x) for x in payload["embedding"]]Uma bandeira que precisamos analisar mais de perto é normalize: True. Pense em cada embedding como uma seta apontando para algum lugar no espaço. Publicações sobre o mesmo tópico apontam aproximadamente na mesma direção. Ao comparar duas publicações, a direção é o que importa, não o comprimento das setas. A receita padrão para isso é a similaridade do coseno: multiplique os dois vetores juntos, depois divida pelo comprimento de cada seta. Essa divisão está lá apenas para cancelar os comprimentos. Com normalize: True, o Titan ajusta cada vetor para um comprimento exatamente de 1 antes de retorná-lo. E dividir por 1 não muda nada. Então a comparação se reduz apenas à etapa de multiplicação, um produto escalar simples. Mesmo resultado, menos matemática, e isso se soma quando cada nova publicação é comparada com todo o catálogo anterior.
A entrada do embed é limitada a 30.000 caracteres antes desta chamada, então publicações muito longas são truncadas em vez de falharem (Titan v2 aceita cerca de 8K tokens).
Ao mesmo tempo, um hash SHA-256 de conteúdo é criado e armazenado com o vetor, com isso eu posso verificar se publicações antigas foram alteradas e precisam ser atualizadas.
new_hash = compute_hash(markdown) # sha256 sobre o corpo normalizado
existing = context.step(
lambda _: find_by_id(slug, language),
name="find_by_id",
)
if not force_recompute and existing and existing.get("content_hash") == new_hash:
return {"skipped": True, "reason": "content_hash_unchanged"}Armazenando os Vetores no MongoDB Atlas
Os vetores criados precisam ser armazenados em algum lugar onde possam ser consultados corretamente. Eu explorei algumas opções diferentes como OpenSearch serverless e Aurora com pgvector. Mas no final decidi executar isso com MongoDB Atlas, que pareceu a melhor escolha para mim. E como posso executar um cluster M0 sob a camada gratuita do MongoDB, isso foi um bônus extra.
O MongoDB Atlas Vector Search executa HNSW sob os panos, o mesmo algoritmo de vizinho mais próximo aproximado por trás da maioria dos bancos de dados vetoriais de produção. A consulta é uma etapa de agregação $vectorSearch, o que significa que o índice vetorial e os documentos vivem em um sistema. Uma consulta retorna vizinhos com seus títulos, resumos e tags. Exatamente os dados que o agente precisa, sem consulta secundária, sem banco de dados vetorial separado para manter sincronizado.
pipeline = [
{"$vectorSearch": {
"index": "posts_vector_idx",
"path": "embedding",
"queryVector": embedding, # 1024 dims, from Titan
"numCandidates": max(100, k * 10),
"limit": k,
"filter": {"language": language}, # evaluated DURING traversal
}},
{"$match": {"slug": {"$nin": exclude_slugs}}},
{"$project": {
"_id": 0,
"slug": 1, "language": 1, "title": 1,
"summary": 1, "tags": 1, "category": 1,
"score": {"$meta": "vectorSearchScore"},
}},
]
return list(_collection().aggregate(pipeline))Essa linha filter é muito importante para mim e meus blogs. Minhas publicações existem em múltiplos idiomas, e o vizinho mais próximo de qualquer publicação é confiável sua própria tradução. O Atlas permite-me declarar language como um campo de filtro dentro do índice vetorial, então a travessia HNSW só considera vetores do mesmo idioma.
Para autenticação, eu uso AWS IAM, sem senhas em lugar nenhum. MongoDB Atlas aceita princípios IAM do AWS como usuários de banco de dados via mecanismo MONGODB-AWS do pymongo. Minhas funções Lambda assumem um papel IAM dedicado através do STS, um papel que foi federado com o Atlas exatamente uma vez, e as credenciais temporárias fluem diretamente para a conexão MongoDB.
def _assume_atlas_role() -> dict:
sts = boto3.client("sts")
response = sts.assume_role(
RoleArn=os.environ["ATLAS_ROLE_ARN"],
RoleSessionName="related-posts-atlas",
DurationSeconds=3600,
)
return response["Credentials"]
def _client() -> MongoClient:
creds = _assume_atlas_role()
return MongoClient(
_connection_config()["srvUri"], # ...authMechanism=MONGODB-AWS
username=creds["AccessKeyId"],
password=creds["SecretAccessKey"],
authMechanismProperties={"AWS_SESSION_TOKEN": creds["SessionToken"]},
)Por que eu uso um papel dedicado em vez do próprio papel de execução da Lambda? Isso é basicamente por dois motivos: em primeiro lugar, eu quero poder lidar com a configuração do MongoDB Atlas em um fluxo. Eu não quero configurar parte do Atlas, depois precisar implantar funções Lambda, puxar os ARNs do Papel e atualizar o Atlas. Separando isso, eu também posso criar um papel que eu autorizo para o Atlas, então várias funções Lambda podem assumir o mesmo papel e eu posso controlar esse acesso corretamente com permissões IAM.
Se você quiser ir mais fundo na autenticação sem senha para o Atlas, eu escrevi sobre Federação de Identidade Saída com MongoDB recentemente, que é uma técnica diferente da que uso aqui.
Editor de Conteúdo de IA com ferramentas
Aqui é onde a parte de IA se afina. O agente não é "chame um LLM com uma prompt". É um loop. Claude Sonnet 4.6 no Bedrock é chamado com um prompt de sistema para encontrar publicações relacionadas, duas ferramentas e ele decide o que chamar, o que ler e quando está pronto.

As duas ferramentas são funções Lambda simples tratadas como ferramentas MCP no AgentCore Gateway. vector_search retorna os top-k candidatos com títulos, resumos, tags e pontuações de similaridade. read_post_excerpt retorna os primeiros 1000 caracteres do corpo de uma publicação, e o agente o chama apenas quando dois candidatos parecem intercambiáveis pelos seus resumos e ele quer quebrar o empate realmente lendo.
Um detalhe de design deliberado em vector_search é que o agente passa o slug da publicação, nunca um embedding. A função Lambda da ferramenta resolve o vetor armazenado do Atlas por si mesma, então o modelo não pode alucinar um array malformado de 1024 floats na fronteira do índice.
Como mencionado, as ferramentas são fronteadas pelo Bedrock AgentCore Gateway. O Gateway transforma minhas funções Lambda em um catálogo de ferramentas que qualquer agente consciente de MCP pode descobrir e chamar, lida com o protocolo para que as Lambdas fiquem livres de protocolo e autentica chamadas inbound com SigV4 IAM simples, sem servidor OAuth para executar. Eu poderia ter pulado o Gateway e passado as ferramentas como funções Python inline? Claro. Mas o próximo agente que eu construir (e haverá um) poderá reutilizar o mesmo catálogo de ferramentas sem eu ter que copiar código por aí.
O Gateway e seus destinos são apenas CloudFormation. Cada destino mapeia uma Lambda para um nome de ferramenta mais um esquema de entrada, e esse esquema é o que o modelo vê quando decide como chamar a ferramenta.
RelatedPostsGateway:
Type: AWS::BedrockAgentCore::Gateway
Properties:
Name: !Sub ${Application}-related-posts-gw
ProtocolType: MCP
RoleArn: !GetAtt GatewayExecutionRole.Arn
AuthorizerType: AWS_IAM
VectorSearchTarget:
Type: AWS::BedrockAgentCore::GatewayTarget
Properties:
GatewayIdentifier: !Ref RelatedPostsGateway
Name: vector-search-target
CredentialProviderConfigurations:
- CredentialProviderType: GATEWAY_IAM_ROLE
TargetConfiguration:
Mcp:
Lambda:
LambdaArn: !GetAtt VectorSearchToolFunction.Arn
ToolSchema:
InlinePayload:
- Name: vector_search
Description: >
Busca vetorial sobre o corpus de publicações do blog. Resolve o
embedding armazenado da publicação fonte do Atlas por source_slug,
então retorna até k candidatos por similaridade semântica.
InputSchema:
Type: object
Properties:
source_slug:
Type: string
language:
Type: string
k:
Type: integer
exclude_slugs:
Type: array
Items:
Type: string
Required:
- source_slugE a função Lambda da ferramenta não sabe nada sobre MCP. O Gateway passa a entrada da ferramenta como o evento, e o valor de retorno se torna a saída da ferramenta.
def handler(event: dict, context) -> dict:
source_slug = event["source_slug"]
language = event.get("language") or "en"
embedding = find_embedding(source_slug, language)
candidates = vector_search_neighbors(
embedding=embedding,
k=int(event.get("k", 20)),
language=language,
exclude_slugs=event.get("exclude_slugs"),
)
return {"candidates": candidates, "count": len(candidates)}O Strands Agents é o framework de agente de código aberto da AWS, dirige o loop agente para que eu não tenha que implementar isso por conta própria.
from mcp_proxy_for_aws.client import aws_iam_streamablehttp_client
from strands import Agent
from strands.models import BedrockModel
from strands.tools.mcp.mcp_client import MCPClient
mcp_client = MCPClient(
lambda: aws_iam_streamablehttp_client(
endpoint=os.environ["AGENTCORE_GATEWAY_URL"],
aws_service="bedrock-agentcore",
)
)
model = BedrockModel(
model_id="eu.anthropic.claude-sonnet-4-6", # EU cross-region inference profile
temperature=0.0,
streaming=False,
)
with mcp_client:
tools = mcp_client.list_tools_sync()
agent = Agent(
model=model,
tools=tools,
system_prompt=_SYSTEM_PROMPT,
callback_handler=_BoundedToolCallHandler(max_calls=8),
)
result = agent(
f"source_slug: {source_slug}\n"
f"source_title: {source_title}\n"
f"source_summary: {source_summary}\n"
f"language: {language}\n\n"
"Escolha 3 publicações relacionadas."
)O comportamento do modelo é moldado quase inteiramente pelo prompt de sistema. Persona, orientação anti-padrão e um contrato estrito.
Você é o editor do jimmydqv.com, um blog técnico sobre AWS, serverless e IA.
Seu trabalho é escolher exatamente 3 publicações relacionadas que um leitor valorizaria mais depois
de terminar a publicação atual.
Prefira profundidade temática sobre sobreposição de palavras-chave superficial.
Cada escolha precisa de uma justificativa de uma frase que nomeie a conexão específica.
Fluxo de trabalho:
1. Chame vector_search com source_slug=, language=, k=20,
exclude_slugs=[]. A ferramenta resolverá o embedding do Atlas
internamente, NÃO tente construir um embedding você mesmo.
2. Se dois candidatos parecerem intercambiáveis, opcionalmente chame read_post_excerpt em um
para quebrar o empate.
3. Retorne ESTRITAMENTE este formato JSON e nada mais:
{"picks": [{"slug": "...", "rationale": "..."}, ... exatamente 3 itens ...]} parsed = json.loads(_strip_fences(raw))
picks_raw = parsed.get("picks", [])
if len(picks_raw) != 3:
raise ValueError(f"O agente deve retornar exatamente 3 escolhas, obteve {len(picks_raw)}")Então, por que optei pelo modelo Sonnet um pouco mais caro e não pelo Haiku mais magro ou mesmo Nova 2? Eu testei vários modelos diferentes. Haiku e Nova 2 Light são ambos mais rápidos e mais baratos, mas não retornaram de forma confiável três ótimas escolhas; às vezes eu obtive duas, às vezes quatro, às vezes escolhas realmente ruins. Sonnet produziu escolhas de forma mais consistente, e uma chamada leva dois a três segundos.
Uma execução típica: uma vector_search, ocasionalmente uma leitura de trecho, então três escolhas com justificativas como "Ambos caminham pelo loop de ferramentas do agente no Bedrock; esta publicação se concentra na orquestração durável em torno dele."
Orquestrando com Lambda Durable Functions
A pipeline leva de 30 a 90 segundos para executar, faz cerca de seis chamadas LLM, fala com quatro sistemas externos. A resposta clássica é Step Functions, e eu usei Step Functions para orquestração muitas vezes. Desta vez eu optei por Lambda Durable Functions em vez disso, e para um workload de agente eu faria o mesmo chamado novamente.
O modelo é simples. Todo o workflow é Python comum em um manipulador, e cada efeito colateral está envolto em context.step(...). O resultado de cada etapa é verificado. Se a invocação falhar, o Bedrock limitar, tempo limite, qualquer coisa, uma nova invocação reinicia o manipulador desde o início, mas as etapas concluídas reproduzem de seus checkpoints em vez de reexecutar.

Transformar uma Lambda comum em uma função durável é uma propriedade SAM. O orquestrador é declarado assim.
RelatedPostsOrchestrator:
Type: AWS::Serverless::Function
Properties:
CodeUri: lambda/orchestrator
Handler: handler.handler
Runtime: python3.13
Timeout: 900
MemorySize: 1024
AutoPublishAlias: live
DurableConfig:
ExecutionTimeout: 3600
RetentionPeriodInDays: 7O manipulador em si parece uma lista de etapas.
@durable_execution
def handler(event: dict, context: DurableContext) -> dict:
slug = event["slug"]
language = event.get("language", "en")
# run_id é não determinístico; compute-o dentro de uma etapa
# para que replays reutilizem o mesmo valor.
run_id = context.step(lambda _: str(uuid.uuid4()), name="generate_run_id")
file_path = context.step(
lambda _: lookup_post_file_path(slug, language),
name="lookup_file_path",
)
fetched = context.step(
lambda _: invoke_fetch_source(file_path, branch, git_ref),
name="fetch_source",
)
# ... content-hash short-circuit ...
embedding = context.step(
lambda _: embed_text(embed_input),
name="embed_post",
)
context.step(lambda _: upsert_post(doc), name="upsert_post")
primary_picks = context.run_in_child_context(
lambda child_ctx: child_ctx.step(
lambda _: _picks_for_one_post(slug, doc["title"], doc["summary"], language),
name="agent_pick_primary",
),
name="primary_agent_picks",
)
if len(primary_picks) != 3:
raise ExecutionError(
f"O agente retornou {len(primary_picks)} escolhas, precisa de exatamente 3"
)
# ... fan-out, persist, PR ...Observe o run_id. Mesmo um uuid.uuid4() tem que viver dentro de uma etapa, porque uma reprodução caso contrário geraria um id diferente e divergiria da trajetória do checkpoint. A chamada do agente é executada em um contexto filho já que orquestra várias invocações de ferramentas MCP nos bastidores, e o contexto filho mantém a árvore de etapas organizada.
O que o modelo de reprodução traz aqui é concreto: quando uma chamada de agente é limitada, a repetição não refete do GitHub, re-embed, ou re-upsert. O trabalho anterior caro reproduz de graça. E a lógica do agente em si, ramificação, análise JSON, "reexecutar para cinco vizinhos", é exatamente o tipo de código que luta com você em uma máquina de estado JSON e lê naturalmente em Python. Fan-out é algumas linhas.
batch = context.parallel(
[_make_backlink_fn(n) for n in backlink_targets],
name="backlink_fan_out",
config=ParallelConfig(max_concurrency=5),
)
# Uma única perna falhada não deve falhar a pipeline; mantenha o que teve sucesso.
for item in batch.succeeded():
result = item.result
if result and len(result.get("picks") or []) == 3:
backlink_results.append(result)Os links de publicações relacionadas para trás
Esta é uma parte interessante da solução e da pipeline.
Encontrar as publicações relacionadas para uma nova publicação é a parte fácil e muito direta. A parte mais difícil é, e se eu adicionar uma nova publicação que agora é uma escolha melhor como publicação relacionada para uma publicação antiga? Isso significaria que eu teria que atualizar publicações antigas também quando uma nova fosse adicionada, vinculando-a para trás. Porque se eu não fizer isso, uma publicação que escrevi há um ano ficaria "congelada" para sempre.

Então depois de embedder a nova publicação, o orquestrador pede ao Atlas seus dez vizinhos mais próximos e reexecuta o agente para os cinco primeiros, cada um como uma etapa durável independente e paralela. A execução do editor de cada vizinho faz a pergunta completa do zero: dado o blog como existe agora, quais são os três melhores? Às vezes a nova publicação desloca uma escolha antiga. Às vezes nada muda.
Nenhum trabalho em lote noturno, nenhum cron. O trabalho acontece exatamente quando a publicação do blog muda, escopada para o vizinhança que mudou.
Lançando as escolhas
A saída do agente tem que terminar em dois lugares, porque o blog é um site estático 11ty.
Amazon DSQL é a fonte da verdade. Uma linha por escolha, escrita como DELETE-e-INSERIR em uma única transação, então reexecuções são idempotentes e uma gravação falhada deixa as escolhas antigas intactas. Eu uso o mesmo configuração DSQL como na minha série AI Bartender, autenticação IAM e tudo.
CREATE TABLE cms_content.related_posts (
source_slug TEXT NOT NULL,
language TEXT NOT NULL,
position SMALLINT NOT NULL,
related_slug TEXT NOT NULL,
rationale TEXT NOT NULL,
similarity_score DOUBLE PRECISION,
run_id UUID NOT NULL,
generated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (source_slug, language, position),
CHECK (position BETWEEN 1 AND 3)
);A gravação é executada em uma única conexão, e o DELETE se desfaz junto com uma INSERT falhada, então a tabela nunca está meio escrita.
conn = connector.get_connection(token, user)
try:
with conn.cursor() as cur:
cur.execute(
"DELETE FROM cms_content.related_posts "
"WHERE source_slug = %(s)s AND language = %(l)s",
{"s": source_slug, "l": language},
)
for position, pick in enumerate(picks, start=1):
cur.execute(
"INSERT INTO cms_content.related_posts "
"(source_slug, language, position, related_slug, "
" rationale, similarity_score, run_id) "
"VALUES (%(src)s, %(lang)s, %(pos)s, %(rel)s, "
" %(rat)s, %(sim)s, %(run)s)",
{...},
)
conn.commit()
except Exception:
conn.rollback()
raiseMeu dashboard CMS lê essa tabela e mostra as escolhas, com justificativas, ao lado de cada publicação. A etapa final do orquestrador emite um evento concluído que flui através do EventBridge para AppSync Events, então o dashboard atualiza em tempo real quando uma execução termina. Sem polling em lugar nenhum.
Um PR do GitHub o torna visível no blog. A pipeline injeta um bloco related_posts: no frontmatter das publicações, até seis arquivos em um PR (a nova publicação mais os vizinhos atualizados). A próxima construção do site renderiza a seção.
related_posts:
- slug: "step-functions-vs-durable-functions"
rationale: "Comparação direta da abordagem de orquestração usada aqui contra a alternativa SFN."Por que um PR em vez de commitar diretamente para main? É assim que todas as minhas diferentes pipelines de melhoria fazem, isso me dá como humano uma verificação final antes de ir ao ar.
Resultado final
No final, a pipeline adicionará as publicações relacionadas no frontmatter, e o processo de construção 11ty as pegará, buscará a imagem de capa e descrição e injetará uma seção na publicação construída real.

Palavras Finais
Eu comecei para manter os leitores lendo, e terminei com algo que acho muito interessante, um padrão. Busca vetorial faz o recall, barato, rápido, matematicamente honesto sobre similaridade. Um agente faz o julgamento, mais lento, mas capaz de ler dois candidatos e decidir qual um humano realmente gostaria a seguir, e dizer por quê. Execução durável envolve tudo isso; um workflow de seis chamadas LLM pode ser tratado casualmente como uma única função. E MongoDB Atlas silenciosamente faz o que toda pipeline de IA precisa: mantém os vetores, os metadados e a filtragem em um lugar consultável, de graça, com identidade IAM em vez de senhas.
Confira minhas outras publicações em jimmydqv.com e me siga no LinkedIn e X para mais conteúdo sobre serverless.
Todo o código para esta publicação pode ser encontrado no handbook serverless como de costume
Agora Vá Construir!