Taller · Claude Community CDMX

Tool usesin abstracciones

del token al tool_result, y las formas en que se rompe

El modelo propone, la aplicación ejecuta, y eso se mide.

Antes de teclear

El mismo modelo falla distinto según quién lo sirve

Predice antes de seguir

¿Por qué cambiaría el mismo modelo?

Candidatos (hipótesis: OpenRouter no desglosa la causa)

  • Cuantización. Mismos pesos, menos bits: otra distribución de tokens.
  • Decodificación restringida, o no. ¿El proveedor fuerza la gramática del JSON o confía en el modelo?
  • Fiabilidad del servicio. Respuestas cortadas, timeouts, un parser distinto de tool calls.
Tasa de error de tool calls · por petición ~8 % ~1 % −88 % antes después GLM-5 5,6 % 3,5 % antes después gpt-oss-120b Antes y después de Auto Exacto: ruteo por calidad de tool calls. Promedio de ruteo; no es un rango entre proveedores.

Fuente: OpenRouter, «Auto Exacto» (blog, publicado 2026-03-12, actualizado 2026-06-24), openrouter.ai/blog/announcements/auto-exacto · consultado 2026-10-18. Métrica: peticiones con error de tool call ÷ peticiones con finish_reason «tool_calls».

Quién habla

Carlos Haro

TrabajoSr. AI Engineer en Santander MéxicoData & AI
ConstruyoAgentes que consultan datoscon herramientas, compuertas y medición
Escribo y enseñoh1sort.comy el curso «IA para Datos» en Código Facilito

Las herramientas son texto en vivo · uv run python -m taller.plantilla

Para el modelo, una llamada a herramienta son tokens

Qwen3-8B · plantilla de chat renderizada (⋯ = recortado)<|im_start|>system Eres un asistente del banco. # Tools ⋯ function signatures within <tools>⋯</tools> XML tags: <tools>{"type": "function", "function": {"name": "sql_query", ⋯}}</tools> ⋯ return a json object ⋯ within <tool_call></tool_call> XML tags:⋯<|im_end|> <|im_start|>user ¿cuántas tarjetas bloqueadas hay?<|im_end|> <|im_start|>assistant <tool_call> {"name": "sql_query", "arguments": {"sql": "SELECT COUNT(*) FROM ⋯"}} </tool_call><|im_end|> <|im_start|>user <tool_response> [{"COUNT(*)": 26}] </tool_response><|im_end|> <|im_start|>assistant

<|im_start|> <|im_end|> = tokens especiales · <tool_call> <tool_response> = tokens añadidos, una sola unidad cada uno

Claude · messages.create devuelve (ilustrativo){ "stop_reason": "tool_use", "content": [ { "type": "tool_use", "id": "toolu_01A⋯", "name": "sql_query", "input": {"sql": "SELECT COUNT(*) FROM ⋯"} } ] }
Tu respuesta en el siguiente mensaje (ilustrativo){ "role": "user", "content": [ { "type": "tool_result", "tool_use_id": "toolu_01A⋯", "content": "[{\"COUNT(*)\": 26}]" } ] }

La API te entrega la llamada ya estructurada. Tu aplicación sigue siendo dueña del bucle.

Otra familia, otro formato: DeepSeek-V3 delimita con <|tool▁calls▁begin|> ⋯ <|tool▁call▁end|> y espera otro parser · --comparar en la terminal

El bucle a nivel cable

El modelo propone; tu aplicación ejecuta y contesta, en orden

Modelo · propone Tu aplicación · ejecuta 1 · envías messages.create tools + messages 2 · responde stop_reason: "tool_use" bloques tool_use · cada uno con su id solicitud: aún sin efecto 3 · ejecutas tus herramientas asyncio.gather si son varias 4 · contestas UN mensaje de usuario tool_result primero · tool_use_id vuelve en el siguiente messages.create, mientras stop_reason == "tool_use" si no pide más 5 · sales stop_reason: "end_turn" respuesta final al usuario
Regla de orden

Los tool_result van en el mensaje de usuario inmediato, y primero en content. Texto antes de ellos: 400.

"tool_use ids were found without tool_result blocks immediately after"
Se emparejan por id

Con llamadas paralelas la API no impone orden entre los resultados: manda el tool_use_id de cada uno.

Docs de Anthropic: handle-tool-calls y parallel-tool-use · verificado 2026-10-18

Tu turno · paso-1 teclean · silencio del orador

Cierra el bucle en taller/agent.py

terminal$ git stash && git checkout paso-0 # abre taller/agent.py: Agent.run · # TODO(paso-1) $ uv run python -m taller.agent tú › ¿cuántas tarjetas bloqueadas hay?
Lo que debe hacer el TODO
  1. Ejecuta todas las llamadas del turno juntas con execute_calls (ya usa asyncio.gather).
  2. Arma UN mensaje {"role": "user", "content": […]}.
  3. Un tool_result por llamada, con su tool_use_id, antes que cualquier texto; is_error si falló.
Checkpoint Mano arriba si el agente respondió 26 con una consulta real. ¿Atrasado? git stash && git checkout paso-1
Trampa antes de que la encuentres

No fuerces la herramienta

# Opus 5.5, Sonnet 5.5, Fable 5.1, Mythos 5.1 tool_choice={"type": "any"} # 400 tool_choice={"type": "tool", ⋯} # 400 tool_choice={"type": "auto"} # + strict: true

Usa auto con strict: true: valida la forma del input, no su sentido.

taller/agent.py · lo que va en el TODO# >>> paso-1 results = await execute_calls(turn.calls, self.tools) messages.append({"role": "user", "content": [ {"type": "tool_result", "tool_use_id": r.tool_use_id, "content": r.content, ⋯} for r in results]}) # <<< paso-1

Fuente de la trampa: docs de Anthropic, define-tools · verificado 2026-10-18

Rotura #1 · huérfano en vivo · uv run python -m taller.romper huerfano

Un tool_use sin su tool_result

Predice antes de correr

El proceso murió entre el tool_use y el tool_result. Reanudas. ¿Qué pasa?

  • AEl modelo nota el hueco y repite la llamada.
  • BLa API rechaza el historial.
  • CEl modelo responde inventando el resultado.
taller.romper huerfano · fragmento de la doc$ uv run python -m taller.romper huerfano RECHAZADA › 400 · ⋯ tool_use ids were found without tool_result blocks immediately after ⋯
Tu turno · paso-2 teclean · silencio del orador
  • Dóndetaller/agent.py · sanitize_history · # TODO(paso-2)
  • QuéPor cada huérfano, un tool_result con is_error, primero en el mensaje siguiente.
  • Verificauv run python -m taller.romper huerfano
taller/agent.py · sanitize_history · TODO(paso-2) · 3 líneas# el esqueleto ya encontró los tool_use sin resultado: orphans if orphans: errores = [{"type": "tool_result", "tool_use_id": t, "is_error": True, "content": HUERFANO_MSG} for t in orphans] fixed[i + 1:hasta] = [{"role": "user", "content": errores + content}] # ↑ los tool_result van PRIMERO en el mensaje de usuario

No se borra nada: is_error y un texto que dice qué pasó y qué intentar. Después, romper huerfano muestra al modelo con el historial saneado.

Checkpoint Mano arriba si el DESPUÉS de romper huerfano ya funciona. ¿Atrasado? git stash && git checkout paso-2

Fuente: docs de Anthropic, handle-tool-calls (fragmento del 400 e is_error) · verificado 2026-10-18

Rotura #1b · paralelas Ejercicio · resuelto en paso-2

Tres llamadas en paralelo. Una no termina.

Predice antes de correr

Tres consultas en paralelo; una tarda 3 s y el tope es 1.5 s. ¿Qué responde la API en la petición siguiente?

  • AEspera a la lenta.
  • B400.
  • CEl modelo inventa el dato.
en vivo · latencia artificial anunciada en pantalla$ uv run python -m taller.romper paraleloOBSERVACIÓN › toolu_…4 → 200 OBSERVACIÓN › toolu_…Y → 26 OBSERVACIÓN › toolu_…8 → SIN tool_result ANTES › devolviste 2 de 3 tool_result: la llamada lenta se perdió RECHAZADA › 400 · tool_use ids were found without tool_result blocks immediately after gather: 1.50 s · una tras otra serían ≥ 4.6 s

La que no termina también regresa.

Es la Rotura #1: el mismo 400, la misma regla. Cada tool_use necesita su tool_result, también cuando la herramienta no terminó.

taller/agent.py · execute_calls · paso-1hechos = await asyncio.gather(*(asyncio.wait_for(one(c), tope) for c in calls), return_exceptions=True) # Versión ingenua: solo las que terminaron results = [r for r in hechos if isinstance(r, ToolResult)] # TODO(paso-2b) un tool_result POR CADA llamada; el timeout vuelve como is_error

Dónde: taller/agent.py · execute_calls · # TODO(paso-2b). Qué: un tool_result por cada llamada; el timeout vuelve como is_error. Verifica: uv run python -m taller.romper paralelo y busca 3 de 3.

paso-2b · lo que tecleas (4 líneas) · resuelto en paso-2# un timeout es un is_error, no un hueco results = [r if isinstance(r, ToolResult) else ToolResult(c.id, ToolOutcome.EXEC_ERROR, f"{c.name} no terminó en {tope:g} s ({type(r).__name__}); reintenta …", True) for c, r in zip(calls, hechos)]
DESPUÉS · con tu arreglotoolu_…h → is_error · sql_query no terminó en 1.5 s (TimeoutError) DESPUÉS › 3 de 3 tool_result en un solo mensaje MODELO › reintenta sql_query
Checkpoint Mano arriba si ya ves 3 de 3. ¿Atrasado o con prisa? git stash && git checkout paso-2

Corrida en vivo 2026-10-18 · claude-sonnet-5-5 · la latencia es artificial y la anuncia el script

Minuto 15 · Decodificación restringida

El esquema se vuelve una máscara sobre los logits

JSON Schema el input_schema de la herramienta Gramática (CFG) reglas: un objeto es { pares } con tipos fijos Autómata de pila la pila recuerda qué se abrió: { [ " Precálculo tokens que no dependen del contexto: de antemano; el resto, al vuelo Modelo · una pasada = un vector de logits Ya se generó {"monto": · ¿qué token sigue? (ejemplo ilustrativo) El esquema dice "monto": {"type": "integer"} → un entero: ni " ni true ni null ni } lo son la pila dice qué bytes caben ahora 12 7 " } true null ✓ sigue ✓ sigue −∞ −∞ −∞ −∞ → los inválidos quedan en −∞ y las probabilidades se renormalizan entre los válidos

Predice · el quiz de la clase

¿Dónde falla aun con gramática?

cada token cumple la gramática { "sql" : "SELECT nombre FROM clie stop_reason "max_tokens" se acabó el espacio lo que faltaba y no llegó → ntes " } Pila del autómata al cortar " abierta { abierta no está vacía ✕ JSON incompleto la gramática valida cada paso, no que lleguemos al final

Rotura #2 · truncado En vivo · uv run python -m taller.romper truncado

La llamada se corta. El daño llega en el siguiente mensaje

Predice antes de correr

max_tokens corta el tool_use y el agente no ejecuta nada. El usuario escribe otra pregunta. ¿Qué responde la API?

  • AResponde normal: no se ejecutó nada.
  • B400 en esa segunda petición.
  • CEl modelo termina de escribir la llamada.
ANTES · paso-2 · fragmento de la docmax_tokens · cortado: {"sql": "SELECT ⋯ FROM clie el bucle sale; el tool_use sigue en el historial siguiente petición → 400: tool_use ids were found without tool_result blocks immediately after
taller/agent.py · el bucle · paso-2turn.calls = [ToolCall(b.id, b.name, b.input) for b in uses] # TODO(paso-3) ¿y si stop_reason == "max_tokens" y hay tool_use? messages.append({"role": "assistant", "content": [… todo …]})

Dónde: taller/agent.py · el bucle de Agent.run · # TODO(paso-3).

Qué: si stop_reason == "max_tokens" y hay tool_use, no lo ejecutes ni lo guardes; sube max_tokens y repite.

Verifica: uv run python -m taller.romper truncado

paso-3 · lo que tecleas (4 líneas) · el cortado ni se guarda ni se ejecutaif resp.stop_reason == "max_tokens" and uses: self.max_tokens = min(self.max_tokens * 4, MAX_TOKENS_TOPE) # más margen self._log(turn) continue # reintenta sin guardar el bloque cortado
Checkpoint Mano arriba si el mensaje siguiente ya no da 400. ¿Atrasado? git stash && git checkout paso-3

Minuto 22 · strict: true

Válido no significa correcto

Lo que strict: true asegura

  • El input sigue el input_schema.
  • El name es una de tus herramientas.
  • Los argumentos llegan con el tipo correcto.

Lo que no puede asegurar

  • Que la llamada sea sensata, autorizada o correcta.
  • Que sea la herramienta adecuada, ni que la tarea salga bien.
  • Reglas que el esquema no expresa: sin minimum/maximum ni minLength/maxLength.
input de sql_query · dos llamadas que cumplen el esquema{"sql": "SELECT nombre FROM clientes LIMIT 5"} ✓ forma válida {"sql": "DROP TABLE tarjetas"} ✓ forma válida · ✕ no debería ejecutarse

Rotura #3 · secretos En vivo · uv run python -m taller.romper drop

La compuerta es determinista: ningún LLM juzga a otro

Modelo Aplicación · runtime de confianza capa 1 · compuerta · determinista · sin LLM tool_use #1 · sql_query SELECT clave, valor FROM secretos válido por esquema: strict no lo para ✕ rechazada secretos no está permitida sin compuerta · paso-3 llegaría a SQLite y devolvería las claves tool_result · is_error: true rechazada: solo se permite leer clientes, cuentas, tarjetas, transacciones; reescribe … (secretos ✕) tool_use #2 · lo decide el modelo ¿se rinde, repite o reescribe? si reescribe a una tabla permitida ✓ SQLite · solo lectura tool_result · filas capa 2 · segunda defensa un DROP TABLE tarjetas que pasara falla aquí (mode=ro)

Paso 4 · la llamada a la compuerta En vivo

Una línea decide: None o un motivo

taller/gate.py · L32–39def _authorizer(action, arg1, arg2, db, trigger): if action in (sqlite3.SQLITE_SELECT, sqlite3.SQLITE_RECURSIVE): return sqlite3.SQLITE_OK if action == sqlite3.SQLITE_FUNCTION and arg2 != "load_extension": return sqlite3.SQLITE_OK if action == sqlite3.SQLITE_READ and arg1 in ALLOWED_TABLES: return sqlite3.SQLITE_OK return sqlite3.SQLITE_DENY # escrituras, DDL, PRAGMA, ATTACH, secretos, load_extension...
taller/tools.py · sql_query (abreviado)def sql_query(sql: str) -> str: motivo = None # TODO(paso-4) validar antes de ejecutar motivo = _gate(sql) # None = permitido if motivo: # rechazada: no se ejecuta return motivo # → is_error: true

Dónde: taller/tools.py · sql_query · # TODO(paso-4).

Qué: llama a la compuerta y deja el resultado en motivo (None = permitido).

Verifica: uv run python -m taller.romper drop

bloquear_tarjeta es una escritura: pide confirmación humana en la terminal antes de ejecutar. La compuerta cubre la lectura; las escrituras las confirma una persona.

Checkpoint

Mano arriba si romper drop ya rechaza secretos y el DROP.

¿Atrasado? git stash && git checkout paso-4

MCP · solo el porqué

El proceso del agente nunca tiene el token de la API

Modelo solo propone sin claves no ve ninguna Agente tu aplicación (el proceso) token de la API ✕ no está aquí el proceso del agente nunca lo recibe Servidor MCP intermediario Servicio externo la API real tool_use llamada + clave MCP clave MCP la del agente llamada + token API token de la API vive solo aquí tool_result Una inyección o una fuga desde el agente no puede sacar un token que el proceso no tiene.

Paso 5 · medir En vivo

La tasa de error se mide, por categoría

terminal · paso 5$ uv run python -m taller.medir # opcional: comparar con otro modelo $ uv run python -m taller.medir --vs claude-opus-5-5

Tokens y costo por tarea resuelta, no por llamada: los intentos fallidos también cuestan.

¿Atrasado? git stash && git checkout paso-5

wrong_tool
eligió otra herramienta, o una donde no hacía falta
bad_args
argumentos inválidos
gate_rejected
válida, pero la compuerta la rechazó Rotura #3
exec_error
la herramienta falló al ejecutarse
truncated
max_tokens cortó el tool_use Rotura #2
orphaned
tool_use sin tool_result Rotura #1

Mi corrida · 18 oct 2026 Medido

Lo que cuesta no es la llamada, es la tarea resuelta

medir --vs · 20 tareas · runs/medicion-*-20261018-151955.json
Métricasonnet-5-5haiku-5-5
tareas resueltas20/2020/20
wrong_tool00
bad_args00
gate_rejected01 (a02)
exec_error00
truncated00
orphaned00
tokens (entrada / salida)67.6K / 5.9K83.5K / 8.1K
costo por tarea resuelta$0.0097$0.0006

Precio por MTok · entrada / salida

claude-haiku-5-5$0.10 / $0.50
claude-sonnet-5-5$2 / $10
claude-opus-5-5$4 / $20

Página de precios, consultada el 2026-10-18. Haiku: prompts de hasta 100K tokens.

Hipótesis vs. mi corrida

Aquí no: Haiku resolvió igual por ~1/16 del costo. Pero intentó leer secretos (a02) y la compuerta lo frenó. Barato y seguro porque hay compuerta.

Cierre

El modelo propone, la aplicación ejecuta — y eso se mide.

Tres roturas, tres arreglos y una tabla con los números de tu propio agente.

h1sort.com/claude4aieng

Basado en la clase CMU 11-768 AI Agents (Graham Neubig y Daniel Fried), lección 2: Tool Use for Language Model Agents.