[CS] CARLOS SEIDL EN
Artigos

API do Claude no ASP.NET Core: streaming e tool use em C#

Streaming com Server-Sent Events e tool use da API do Claude no ASP.NET Core 10 com o SDK oficial em C#: três armadilhas e testes xUnit.

Por 9 min de leitura
  • IA
  • Claude
  • ASP.NET Core
  • .NET 10
Capa do artigo: API do Claude no ASP.NET Core: streaming e tool use em C#

Para usar a API do Claude no ASP.NET Core com streaming, você chama client.Messages.CreateStreaming do SDK oficial da Anthropic para .NET (pacote NuGet Anthropic), filtra os pedaços de texto num IAsyncEnumerable e devolve esse enumerável com TypedResults.ServerSentEvents, que no .NET 10 escreve o text/event-stream para o navegador. Tool use é um laço no seu servidor: o modelo pede uma função pelo nome e com argumentos em JSON, o seu código C# valida, executa e devolve o resultado. Neste artigo eu mostro as duas coisas e três armadilhas que eu rodei de verdade: um middleware que captura o corpo e mata o streaming, o CancellationToken que não chega ao modelo e o laço de ferramenta sem limite de rodadas.

Um aviso antes: a API da Anthropic foi simulada

Tudo o que está aqui rodou na minha máquina sem chave e sem custo. A API da Anthropic foi simulada por um servidor falso local, que responde ao mesmo POST /v1/messages com eventos no formato da documentação, inclusive os de ferramenta, mas com texto fixo. Não há resultado de modelo real neste artigo. O que é real: o SDK oficial (Anthropic 13.1.0), a API ASP.NET Core 10, o streaming por Server-Sent Events, o laço de ferramenta e os testes xUnit. O id de modelo no código é claude-sonnet-5-5; modelos mudam, então confira na documentação antes de copiar.

O modelo mental: navegador, minha API e o Claude

O navegador fala com a minha API, e a minha API fala com o Claude. A resposta do modelo chega em eventos, e eu repasso esses eventos para o navegador em Server-Sent Events, que um EventSource consegue ler.

Com ferramenta, o modelo nunca executa nada. Ele pede, com o nome e os argumentos, e quem decide e executa é o meu código. Essa é a fronteira de segurança: o modelo não tem permissão própria, ele herda a do usuário logado.

Cliente único e chave fora do código

O cliente do SDK é registrado uma vez, como singleton, para reaproveitar conexões. Por padrão ele aponta para a API real; a variável ANTHROPIC_BASE_URL serve para apontar para outro servidor, que no meu caso foi o falso. A chave vem de ANTHROPIC_API_KEY, nunca do código. Em produção, use User Secrets ou variável de ambiente.

C#
// Program.cs
var options = new ChatOptions
{
    BaseUrl = Environment
        .GetEnvironmentVariable("ANTHROPIC_BASE_URL")
        ?? "https://api.anthropic.com",
    ApiKey = Environment
        .GetEnvironmentVariable("ANTHROPIC_API_KEY"),
};
ChatApp.Build("http://localhost:5100", options).Run();

// ChatApp.cs: one client for the whole app, reusing connections.
builder.Services.AddSingleton(_ => new AnthropicClient
{
    BaseUrl = options.BaseUrl,
    ApiKey = options.ApiKey,
});

Streaming da resposta com o SDK oficial

O serviço monta o pedido (modelo, máximo de tokens e a mensagem do usuário) e pede o stream. O SDK entrega cada evento assim que ele chega, e eu fico só com os deltas de texto. Repare no ct: ele é o token da requisição HTTP e vai até a chamada ao modelo. Isso importa na segunda armadilha.

C#
MessageCreateParams Request(string question) => new()
{
    Model = options.Model,
    MaxTokens = 1024,
    Messages = [new UserMessageParam(question)],
};

public async IAsyncEnumerable<string> StreamAsync(string question,
    [EnumeratorCancellation] CancellationToken ct)
{
    await foreach (var ev in client.Messages
        .CreateStreaming(Request(question), ct))
    {
        if (ev.TryPickContentBlockDelta(out var delta) &&
            delta.Delta.TryPickText(out var text))
            yield return text.Text;
    }
}

Server-Sent Events no ASP.NET Core 10

No endpoint, o .NET 10 faz o trabalho do protocolo: eu devolvo o IAsyncEnumerable e o framework escreve cada item como um evento. A primeira versão, com POST /chat, devolvia a resposta inteira de uma vez. Funciona, mas o usuário espera tudo ficar pronto para ver qualquer coisa.

C#
// The whole answer in one response (the first version).
app.MapPost("/chat", async (AskRequest r, ClaudeChat chat,
    CancellationToken ct) =>
    new { answer = await chat.AskAsync(r.Question, ct) });

// Token by token, as Server-Sent Events.
app.MapGet("/chat/stream", (string q, ClaudeChat chat,
    CancellationToken ct) =>
    TypedResults.ServerSentEvents(
        chat.StreamAsync(q, ct), "token"));

Medi com um programa pequeno que faz o papel do navegador e imprime o segundo de cada evento. Resposta inteira: 4,9 segundos de tela parada e só então o texto. Com streaming: 27 eventos, o primeiro com 0,5 segundo e o último com 4,6. Lembre que os tempos vêm do servidor falso, que espera 150 ms entre um pedaço e outro.

O que passa no fio quando o modelo pede uma ferramenta

Quando a requisição declara uma ferramenta e o modelo decide usá-la, chega um bloco tool_use com id e nome. Os argumentos vêm em pedaços, em eventos input_json_delta, e o JSON só é válido quando o último pedaço chega. No fim, o stop_reason é tool_use: o modelo está esperando o meu resultado. Estas linhas vieram do servidor simulado, escritas conforme a documentação (quebrei as linhas para caber):

Texto
data: {"type":"content_block_start","index":1,
  "content_block":{"type":"tool_use","id":"toolu_fake_01",
  "name":"get_order","input":}}
data: {"type":"content_block_delta","index":1,
  "delta":{"type":"input_json_delta",
  "partial_json":"{\"order_id\""}}
data: {"type":"content_block_delta","index":1,
  "delta":{"type":"input_json_delta",
  "partial_json":": \"A-1042\"}"}}
data: {"type":"message_delta","delta":{"stop_reason":"tool_use",
  "stop_sequence":null},"usage":{"output_tokens":15}}

Tool use: a ferramenta e o argumento como entrada não confiável

A ferramenta tem nome, descrição e o esquema dos parâmetros, que é o que o modelo enxerga. A execução trata o argumento como entrada não confiável, igual a uma API pública: valida o formato do id e confere se o pedido é do usuário. Para o modelo, o pedido de outra pessoa simplesmente não existe. Na demo, o usuário é fixo ("dana"), passado pelo endpoint; numa aplicação real ele viria da autenticação.

C#
public static Tool Definition => new()
{
    Name = Name,
    Description =
        "Looks up an order of the current user by its id.",
    InputSchema = new()
    {
        Properties = new Dictionary<string, JsonElement>
        {
            ["order_id"] = JsonSerializer.SerializeToElement(
                new { type = "string",
                      description = "e.g. A-1042" }),
        },
        Required = ["order_id"],
    },
};

// The model's arguments are untrusted input, like a public API.
public static string Run(string inputJson, string user)
{
    using var doc = JsonDocument.Parse(inputJson);
    if (!doc.RootElement.TryGetProperty("order_id", out var p) ||
        p.ValueKind != JsonValueKind.String ||
        !IdFormat().IsMatch(p.GetString()!))
        throw new ToolInputException(
            "order_id must look like A-1042");

    var id = p.GetString()!;
    if (!Orders.TryGetValue(id, out var order) ||
        order.Owner != user)
        throw new ToolInputException($"order {id} not found");
    // ... serializes orderId, status and eta
}

O laço de tool use com streaming

A cada rodada eu chamo a API com streaming e o texto vai para o usuário na hora. Se chega um bloco tool_use, guardo id e nome e junto os pedaços do JSON. Sem pedido de ferramenta, acabou. Com pedido, envio um evento status, para a interface mostrar "consultando pedido" em vez de parecer travada, executo a ferramenta e devolvo o resultado no histórico. Se a ferramenta falha, o erro volta como tool_result com IsError = true: eu nunca engulo a exceção, senão o modelo inventa uma resposta.

C#
for (var round = 1; round <= options.MaxToolRounds; round++)
{
    var calls = new Dictionary<long, Call>();
    await foreach (var ev in client.Messages.CreateStreaming(
        Request(history), ct))
    {
        if (ev.TryPickContentBlockStart(out var start) &&
            start.ContentBlock.TryPickToolUse(out var use))
            calls[start.Index] = new(use.ID, use.Name);
        else if (ev.TryPickContentBlockDelta(out var delta))
        {
            if (delta.Delta.TryPickText(out var text))
                yield return new("token", text.Text);
            else if (delta.Delta.TryPickInputJson(out var json))
                calls[delta.Index].Json.Append(json.PartialJson);
        }
    }
    if (calls.Count == 0)
    {
        yield return new("done", "");
        yield break;
    }
    // status event, Execute(call, input, user), history updated
}
yield return new("error",
    $"stopped after {options.MaxToolRounds} tool rounds");

Omiti o acúmulo do texto dito pelo modelo, que volta no histórico. Na saída medida do /agent/stream, o texto começa com 1,1 segundo, o evento status calling get_order... aparece com 1,7 segundo, e a resposta com o status do pedido A-1042 termina com 3,0 segundos, em 14 eventos.

Armadilha: o middleware que captura o corpo e mata o streaming

Eu queria registrar o tamanho de cada resposta e escrevi um middleware que troca o Response.Body por um MemoryStream. Para saber o tamanho, ele segura o corpo inteiro até o fim. Com ele, o stream entregou 27 eventos, mas o primeiro chegou com 4,9 segundos, junto com o último. Um proxy com buffering faz o mesmo. A correção: as rotas de stream passam direto, sem captura.

C#
app.Use(async (ctx, next) =>
{
    // A stream must reach the client while it is being written.
    if (ctx.Request.Path.Value!.EndsWith("/stream"))
    {
        await next();
        return;
    }

    var original = ctx.Response.Body;
    await using var buffer = new MemoryStream();
    ctx.Response.Body = buffer;
    await next();
    // ... logs buffer.Length and copies it to the original body
});

Também testei a compressão de resposta com text/event-stream, e no .NET 10 ela não segurou os eventos. Esse caso eu não consegui reproduzir.

Armadilha: o CancellationToken que não chega ao modelo

Na primeira versão, a chamada era client.Messages.CreateStreaming(request), sem o token. O teste simula uma aba que fecha: o cliente lê três eventos e desconecta sem ler o resto. Na API de verdade, a geração que continua depois disso são tokens cobrados por uma resposta que ninguém vai ler. A correção é a que já apareceu no StreamAsync: passar o ct da requisição até o modelo.

Armadilha: o laço de ferramenta sem limite de rodadas

O meu primeiro laço era um while (true) que continuava enquanto o modelo pedisse ferramenta. Pedi o pedido X-9999, que não existe, e a ferramenta devolveu erro. O servidor falso foi programado para insistir na ferramenta depois de um erro; um modelo de verdade pode ou não fazer isso, eu forcei para mostrar a defesa. Cada chamada reenvia o histórico inteiro, então em produção isso é custo e latência. A correção é o for com MaxToolRounds (5 por padrão) e uma saída clara, o evento error.

As saídas reais das três armadilhas, antes e depois da correção:

Texto
buffering antes:  events: 27 | first: 4.9 s | last: 4.9 s
buffering depois: events: 27 | first: 0.3 s | last: 5.1 s

cancel antes:  model deltas generated: 27 of 27
               model streams cancelled: 0
cancel depois: model deltas generated: 3 of 27
               model streams cancelled: 1

loop antes:  loop still running after 2 s: stopped
             model calls: 53
loop depois: model calls: 5
             last event: error - stopped after 5 tool rounds

Testes xUnit para cada regra

Os testes sobem a minha API e o servidor falso em portas locais e conversam por HTTP de verdade. A preparação esquenta o JIT, para o tempo medido ser o do streaming. São seis: a primeira palavra chega antes da metade do tempo do stream; o cliente desconecta depois de três eventos e o servidor falso registra o cancelamento; com o pedido que falha, o servidor recebe exatamente cinco chamadas; o resultado da ferramenta chega na resposta; a ferramenta rejeita o pedido de outro usuário; e rejeita o id malformado.

C#
[Fact]
public async Task Tool_loop_stops_at_the_round_limit()
{
    FakeServer.TokenDelayMs = 5;
    var events = await ReadAll("/agent/stream?q=order%20X-9999");

    Assert.Equal(5, FakeServer.Requests);
    Assert.Contains("stopped after 5 tool rounds", events.Last());
}

Com dotnet test, seis aprovados. Depois eu estraguei o limite, trocando 5 por 50, e o teste quebrou:

Texto
Passed!  - Failed: 0, Passed: 6, Skipped: 0, Total: 6,
          Duration: 6 s

Failed Tool_loop_stops_at_the_round_limit [4 s]
Assert.Equal() Failure: Values differ
Expected: 5
Actual:   50
Failed!  - Failed: 1, Passed: 5, Skipped: 0, Total: 6,
          Duration: 10 s

O que não foi reproduzido

A API foi simulada, então respostas, tempos e custo de um modelo de verdade vão ser outros, e os eventos foram escritos conforme a documentação, sem captura da API real. Não testei um navegador de verdade (o cliente foi um programa .NET) nem um proxy como o nginx, onde o buffering costuma aparecer. Meça com a API real antes de confiar nos números daqui.

Resumo

  • CreateStreaming do SDK Anthropic mais TypedResults.ServerSentEvents entregam a resposta enquanto ela é gerada.
  • Nada no caminho pode capturar o corpo de um stream: nem middleware, nem proxy com buffering.
  • Passe o CancellationToken da requisição até a chamada ao modelo.
  • No tool use, o modelo só pede; o seu código valida o argumento como entrada não confiável, limita as rodadas e devolve o erro como resultado com IsError.
  • Um teste xUnit protege cada regra.

Se você quer ir além, leia também MCP em .NET, para expor ferramentas C# a modelos por um protocolo padrão, a ordem dos middlewares no .NET, que explica por que a posição do middleware de log importa, e RAG em .NET. Se precisa de ajuda para levar uma API de IA para produção, veja o serviço de backend .NET.

Referências

  • Streaming de mensagens (Anthropic): https://docs.claude.com/en/docs/build-with-claude/streaming
  • Tool use (Anthropic): https://docs.claude.com/en/docs/agents-and-tools/tool-use/overview
  • Pacote NuGet Anthropic: https://www.nuget.org/packages/Anthropic
  • Respostas em Minimal APIs, incluindo Server-Sent Events: https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/responses
  • Novidades do ASP.NET Core 10: https://learn.microsoft.com/en-us/aspnet/core/release-notes/aspnetcore-10.0

← todos os artigos