Saltar al contenido
← Volver

Trabajando para el bien común. Cómo contribuí a llama.cpp para que modelos thinking funcionen con la API de Anthropic

5 min de lectura
C++Anthropic APIStreamingllama.cppOpen SourceLLM

Añadí soporte para thinking content blocks en la API de Anthropic Messages de llama.cpp. Modelos como DeepSeek-R1 y Qwen3-Thinking ahora funcionan correctamente con Claude Code y otros clientes que siguen la especificación de Anthropic.

Llevas días probando modelos de razonamiento como DeepSeek-R1 o Qwen3-Thinking con llama.cpp. El servidor arranca, el modelo carga, envías una petición usando la API de Anthropic y… algo falla. Claude Code se queja de respuestas malformadas. El streaming no funciona bien. Pides “Hello World” y solo recibes “World”.

Lo primero que piensas es que es tu configuración. Otra variable de entorno mal puesta, otro parámetro que te falta. Pasas página y sigues con otra cosa.

Hasta que un día buscas el error en GitHub y encuentras un issue. Y otro. Y otro más. Gente con el mismo problema exacto, diferentes modelos, diferentes sistemas operativos, mismo resultado roto. Piensas “menos mal, no soy yo” —mal de muchos, consuelo de tontos— pero también piensas otra cosa: si nadie lo arregla, esto va a seguir roto para todos.

Así que decides ponerte a que funcione.

El problema: variables que se reinician donde no deben

El issue #18613 lo describía perfectamente. Un desarrollador pedía a llama-server que dijera “Hello World” y solo recibía “World”. Los logs del servidor mostraban que internamente generaba los tres tokens correctamente —“Hello”, ” World”, fin de secuencia— pero la respuesta JSON solo contenía el último.

El bug estaba en cómo llama.cpp manejaba el streaming de la API de Anthropic. Cada vez que se procesaba un chunk de tokens, las variables que controlaban si un bloque de contenido ya había empezado se declaraban como locales. Se reiniciaban a false en cada iteración. Resultado: en lugar de enviar un content_block_start al principio y luego deltas de contenido, el servidor enviaba un nuevo inicio de bloque por cada token. El cliente se volvía loco intentando parsear eso.

Pero había un segundo problema más específico de los modelos thinking. La API de Anthropic espera un campo signature en los bloques de pensamiento. En modo no-streaming tiene que estar presente aunque sea vacío. En streaming, tiene que haber un evento signature_delta antes de cerrar cada bloque. llama.cpp no enviaba ninguno de los dos.

La solución: estado persistente y firmas donde tocan

El fix requirió cambios en tools/server/server-task.h. Primero, mover el tracking de bloques a la estructura task_result_state para que persista entre llamadas:

// En task_result_state (persistente entre chunks)
bool anthropic_thinking_block_started = false;
bool anthropic_text_block_started = false;

Esto garantiza un único content_block_start por bloque, sin importar cuántos tokens se generen después.

Segundo, añadir los eventos de firma que faltaban:

// Antes de cerrar un bloque thinking en streaming
if (state.anthropic_thinking_block_started) {
    // signature_delta vacío (requerido por la spec de Anthropic)
    events.push_back({
        {"type", "content_block_delta"},
        {"delta", {{"type", "signature_delta"}, {"signature", ""}}}
    });
}

En modo no-streaming, el campo signature se añade directamente al objeto del bloque thinking.

El proceso de revisión

El PR pasó por varias iteraciones. Uno de los maintainers señaló que usar un puntero raw al estado era un patrón problemático. Tenía razón. Refactoricé el código para copiar los valores directamente en la función update(), eliminando el riesgo de referencias inválidas.

Después de 27 tests de compatibilidad con la API de Anthropic y pruebas manuales con DeepSeek-R1-Distill-Qwen-7B y Qwen3-4B-Thinking, el PR fue aprobado. El desarrollador que había reportado el issue original confirmó que ahora recibía “Hello World” completo.

Por qué importa esto

Ollama anunció que su versión 0.14.0 es compatible con la API de Anthropic Messages (ver noticia aquí). Puedes usar Claude Code con modelos open source locales. Extended thinking, function calling, streaming, todo funciona.

Parte de esa compatibilidad depende de que el servidor implemente correctamente la especificación. Cuando alguien ejecuta Claude Code contra un modelo local que soporta razonamiento, los thinking blocks se serializan bien y el cliente no explota.

Es una pieza pequeña en un ecosistema grande. Pero cada vez que un desarrollador puede usar modelos de razonamiento open source con herramientas que antes solo funcionaban con APIs propietarias, algo mejora para todos.

El código abierto funciona así. Alguien encuentra un bug, otro lo arregla, y miles de personas que nunca sabrán que existió el problema se benefician sin darse cuenta. Trabajar para el bien común.