Registro Estructurado de Prompts, Respuestas y Conteo de Tokens
Un registro estructurado es un registro consistente y analizable por máquina de una llamada a la API de Claude: el prompt, la respuesta, los conteos de tokens, el modelo, la latencia y un ID de solicitud.
Sin esa consistencia, cada sitio de llamada registra algo ligeramente diferente, y responder "¿cuántos tokens usamos ayer?" se convierte en buscar en texto inconsistente en lugar de ejecutar una consulta.
Resumen
El objetivo del registro estructurado es un esquema fijo aplicado a cada llamada, no un mensaje de registro de mejor esfuerzo.
Cada campo del esquema se gana su lugar: el prompt y la respuesta para depurar problemas de calidad, los conteos de tokens para el costo, la latencia para el rendimiento, el nombre del modelo para comparar entre versiones del modelo y un ID de solicitud para correlacionar con rastreos o tickets de soporte.
Un logging.Logger de Python con un formateador JSON, o una función adaptadora de registro dedicada, es suficiente para aplicar esto sin adoptar un nuevo framework.
El patrón se generaliza más allá de una sola llamada a messages.create: las respuestas en streaming, las conversaciones de múltiples turnos y los bucles de agentes que usan herramientas necesitan el mismo esquema aplicado por llamada, no por conversación.
Esta página crea un adaptador de registro reutilizable, y luego lo extiende a casos de múltiples turnos y streaming.
Receta
import json
import logging
import time
import uuid
logger = logging.getLogger("claude.calls")
def log_call(*, request_id, model, prompt, response_text, usage, latency_ms, status="ok"):
logger.info(json.dumps({
"request_id": request_id,
"model": model,
"prompt": prompt,
"response": response_text,
"input_tokens": usage.input_tokens,
"output_tokens": usage.output_tokens,
"latency_ms": latency_ms,
"status": status,
}))Cuándo usar esto:
- Tienes más de un sitio de llamada realizando llamadas a la API de Claude y deseas un formato de registro consistente y consultable.
- Necesitas responder "¿cuántos tokens usó esta característica la semana pasada?" desde los registros, no volviendo a ejecutar las solicitudes.
- Estás a punto de agregar paneles de rastreo o costos, ambos dependen de que este esquema exista primero.
- Quieres registros de errores y éxitos con la misma forma para que una sola consulta cubra ambos.
Ejemplo de Trabajo
import json
import logging
import time
import uuid
import anthropic
logging.basicConfig(level=logging.INFO, format="%(message)s")
logger = logging.getLogger("claude.calls")
client = anthropic.Anthropic()
def log_entry(**fields) -> None:
"""Emite una línea de registro JSON estructurada, omitiendo cualquier campo que sea None."""
logger.info(json.dumps({k: v for k, v in fields.items() if v is not None}))
def call_claude(prompt: str, model: str = "claude-sonnet-5") -> str:
"""Realiza una llamada a Claude y registra una entrada estructurada para ella, éxito o fracaso."""
request_id = str(uuid.uuid4())
start = time.monotonic()
try:
response = client.messages.create(
model=model,
max_tokens=500,
messages=[{"role": "user", "content": prompt}],
)
except anthropic.APIStatusError as exc:
log_entry(
request_id=request_id,
model=model,
prompt=prompt,
response=None,
input_tokens=None,
output_tokens=None,
latency_ms=round((time.monotonic() - start) * 1000, 1),
status="error",
error_type=exc.__class__.__name__,
status_code=exc.status_code,
)
raise
response_text = response.content[0].text
log_entry(
request_id=request_id,
model=response.model,
prompt=prompt,
response=response_text,
input_tokens=response.usage.input_tokens,
output_tokens=response.usage.output_tokens,
cache_read_input_tokens=getattr(response.usage, "cache_read_input_tokens", None),
latency_ms=round((time.monotonic() - start) * 1000, 1),
status="ok",
)
return response_text
def call_claude_multi_turn(messages: list[dict], model: str = "claude-sonnet-5") -> dict:
"""Mismo esquema, aplicado a un turno de una conversación de múltiples turnos."""
request_id = str(uuid.uuid4())
start = time.monotonic()
response = client.messages.create(
model=model,
max_tokens=500,
messages=messages,
)
log_entry(
request_id=request_id,
model=response.model,
prompt=messages[-1]["content"],
response=response.content[0].text if response.content else "",
input_tokens=response.usage.input_tokens,
output_tokens=response.usage.output_tokens,
latency_ms=round((time.monotonic() - start) * 1000, 1),
status="ok",
turn_count=len(messages),
)
return {"request_id": request_id, "response": response}
if __name__ == "__main__":
call_claude("Summarize the tradeoffs of microservices in two sentences.")Lo que esto demuestra:
- Una única función
log_entryque aplica un esquema, llamada tanto desde las rutas de éxito como de error. - Omitir los campos
Noneevita que las líneas de registro de errores contengan campos de respuesta/token vacíos engañosos. request_idgenerado antes de la llamada, por lo que existe incluso cuando la llamada falla.cache_read_input_tokenscapturado congetattrya que solo está presente una vez que se aplica el almacenamiento en caché del prompt.- El mismo esquema reutilizado para una llamada de múltiples turnos, con un campo
turn_countadicional, que muestra que el esquema se extiende en lugar de reemplazarse por tipo de llamada.
Inmersión Profunda
Cómo Funciona
- El esquema es solo un diccionario de Python serializado a JSON; no se requiere ninguna biblioteca especial, aunque una biblioteca de registro JSON dedicada (
python-json-logger,structlog) elimina el código repetitivo manual dejson.dumpsa escala. - Centralizar la llamada de registro en una sola función (
log_entry) es lo que realmente aplica el esquema; si cada sitio de llamada construye su propio diccionario en línea, los campos se desvían con el tiempo. - Se utiliza
logging.Logger(noprint) porque se integra con el filtrado de niveles de registro, manejadores y la mayoría de los agentes de agregación de registros (Datadog Agent, Fluent Bit, CloudWatch Logs agent) listos para usar. - Las rutas de error y éxito utilizan el mismo conjunto de campos, distinguiéndolos el campo
status, por lo que una sola consulta comostatus:erroro una agregación sobreinput_tokensfunciona independientemente del resultado.
Referencia del Esquema
| Campo | Tipo | Siempre Presente | Notas |
|---|---|---|---|
request_id | string | sí | Generado antes de la llamada, se une a los tramos de rastreo |
model | string | sí | El nombre del modelo utilizado para la solicitud |
prompt | string | sí | Texto completo del prompt enviado |
response | string | solo en caso de éxito | Texto completo de la respuesta |
input_tokens / output_tokens | int | solo en caso de éxito | De response.usage |
cache_read_input_tokens | int | solo cuando se aplica el caché | Presente una vez que se utiliza el almacenamiento en caché del prompt |
latency_ms | float | sí | Tiempo de reloj de pared para la llamada |
status | string | sí | "ok" o "error" |
error_type / status_code | string / int | solo en caso de error | De la excepción capturada |
Notas de Python
- Prefiere
logging.Loggersobreprintincluso para scripts; no cuesta nada extra y significa que el mismo código funciona sin modificaciones una vez que agregas manejadores para un agregador de registros real. - Usa
logger.info(json.dumps(...))para una configuración mínima; para servicios de alto rendimiento, una subclase delogging.Formatterque codifica en JSON elLogRecordcompleto evita la doble serialización y mantiene las trazas de pila intactas en caso de errores. - Redacta o trunca los campos
prompt/responseantes de registrar si tu aplicación maneja datos de usuario regulados o sensibles; un esquema estructurado facilita esto, ya que la redacción se convierte en una función, no en una decisión por sitio de llamada.
Trampas Comunes
- Construir el diccionario de registro en línea en cada sitio de llamada. Sin una función
log_entrycompartida, un sitio de llamada agrega un campo y otro no, y tu esquema se desvía silenciosamente. Solución: centraliza la construcción del registro en una función o una pequeña clase adaptadora de registro. - Registrar los campos
usageantes de verificar si hay una excepción. Si la llamada falla,responsenunca se asigna, y hacer referencia aresponse.usageen un bloquefinallygenera un errorNameError. Solución: calcula los campos de tokens solo dentro de la rama de éxito y registraNonepara ellos en la ruta de error. - La falta de
cache_read_input_tokensrompe las matemáticas de costos posteriores. Los campos de uso relacionados con el caché no siempre están presentes en el objeto de respuesta dependiendo de la versión del SDK y de si se aplicó el caché. Solución: accédelos congetattr(response.usage, "cache_read_input_tokens", None)en lugar de acceso directo al atributo. - Registrar el texto completo del prompt/respuesta con un nivel de registro muy alto en producción. Esto puede aumentar drásticamente los costos de almacenamiento de registros y, si los prompts contienen PII del usuario, crea una superficie de cumplimiento que no pretendías. Solución: aplica la redacción/truncamiento antes de registrar y confirma tu política de retención de registros para esta transmisión de acuerdo con tus requisitos de manejo de datos.
- Reutilizar
print(json.dumps(...))en lugar del módulo de registro. Esto omite el filtrado de niveles de registro y la mayoría de los agentes de agregación de registros, que esperan la salida en stdout/stderr a través de un framework de registro o esperan un formato de archivo específico. Solución: siempre enruta a través delogging.Logger, incluso para un script rápido.
Alternativas
| Alternativa | Usar Cuando | No Usar Cuando |
|---|---|---|
Declaraciones print ad hoc | Un script de depuración único que eliminarás en una hora | Cualquier código que se ejecutará más de una vez o que será leído por un compañero de equipo |
Una biblioteca de registro estructurado dedicada (structlog) | Quieres enlace de contexto automático y menos código repetitivo JSON a escala | Un script pequeño donde la dependencia adicional no vale la pena |
| Atributos de span de OpenTelemetry en lugar de registros | Solo necesitas metadatos cortos y consultables (tokens, modelo, latencia) | Necesitas preservar el texto completo del prompt/respuesta, que los spans no están diseñados para transportar |
| Una plataforma de observabilidad de LLM administrada con su SDK | Quieres captura de prompt/respuesta con una interfaz de usuario lista para usar y configuración mínima | Necesitas control total sobre el esquema o debes evitar enviar el contenido del prompt a un tercero |
Preguntas Frecuentes
¿Por qué no usar simplemente `print()` para registrar las llamadas a Claude durante el desarrollo?
La salida de print no está estructurada, no se integra con el filtrado de niveles de registro, y la mayoría de las herramientas de agregación de registros (Datadog Agent, CloudWatch Logs, Fluent Bit) esperan la salida a través de un framework de registro para analizarla de manera confiable. Usar logging.Logger desde el principio no cuesta nada adicional y significa que el mismo código funciona una vez que agregas manejadores reales.
¿Debo registrar el texto completo del prompt y la respuesta, o solo un resumen?
Registra el texto completo cuando puedas, ya que los registros truncados hacen que la depuración de problemas de calidad sea mucho más difícil. Si tus prompts o respuestas contienen datos confidenciales del usuario, agrega redacción antes de registrar en lugar de omitir los campos por completo, para mantener la capacidad de depuración sin el riesgo de cumplimiento.
¿Cuál es el conjunto mínimo de campos que necesita una entrada de registro estructurada?
request_id,model,prompt,response,input_tokens,output_tokens,latency_ms, ystatus.- Todo lo demás (tokens de caché, detalles de error, recuento de turnos) es adicional sobre ese esquema base.
¿Cómo mantengo la consistencia del esquema en muchos sitios de llamada en una base de código grande?
Centraliza el registro en una función o una pequeña clase adaptadora que todos los sitios de llamada utilicen, en lugar de permitir que cada sitio de llamada construya su propio diccionario de registro. Esta es la palanca más importante para la consistencia del esquema; sin ella, los campos se desvían en cuestión de semanas.
¿Qué sucede con `response.usage` si la llamada a la API genera una excepción?
try:
response = client.messages.create(...)
except anthropic.APIStatusError as exc:
# response nunca fue asignado; registra None para los campos de uso en su lugar
log_entry(status="error", error_type=exc.__class__.__name__, input_tokens=None)
raiseNo hay un objeto response para leer usage de él, por lo que la rama de error debe registrar None (u omitir) los campos de uso en lugar de intentar hacer referencia a una variable no asignada.
¿Las respuestas en streaming requieren un enfoque de registro diferente?
El esquema sigue siendo el mismo, pero registras una vez después de que se completa el stream, usando stream.get_final_message() para los números de uso finales, en lugar de registrar por fragmento. Registrar dentro del bucle de fragmentos produciría una línea de registro por fragmento de token en lugar de una por llamada.
¿Por qué incluir un `request_id` si aún no estás usando rastreo?
- Prepara tus registros para la correlación con el rastreo una vez que lo agregues.
- También es útil por sí solo para correlacionar el informe de errores de un usuario con la llamada exacta que produjo su resultado.
- Generarlo no cuesta nada y es una sola llamada a
uuid.uuid4().
¿Está `cache_read_input_tokens` siempre presente en la respuesta?
No, solo se rellena significativamente una vez que se utiliza el almacenamiento en caché del prompt para esa llamada. Accédelo de forma defensiva con getattr(response.usage, "cache_read_input_tokens", None) para que tu código de registro no falle en las llamadas que no usan caché.
¿Los registros de errores deben usar exactamente el mismo esquema que los registros de éxito?
Sí, con status como el campo que los distingue y los campos de token/respuesta establecidos en None en caso de error. Mantener el esquema compartido significa que una consulta (por ejemplo, latencia promedio, o recuento agrupado por estado) funciona en ambos resultados sin formatos de registro separados.
¿Cómo se conecta este esquema a un panel de costos?
Los campos input_tokens, output_tokens y model son exactamente lo que necesita un cálculo de costos; un transportador de registros o un trabajo por lotes lee estas líneas de registro estructuradas, multiplica por el precio por modelo y reenvía el resultado a un panel. Sin un esquema consistente, ese cálculo no tiene nada confiable para analizar.
¿Cuál es un nivel de registro razonable para estas entradas?
INFO para llamadas exitosas es lo típico, ya que son eventos esperados y de alto volumen que aún deseas conservar para análisis de costos y uso. ERROR (o WARNING, dependiendo de la severidad) se ajusta a la ruta de fallo, lo que te permite filtrar paneles y alertas por nivel además del campo status.
Relacionado
- Conceptos Básicos de Observabilidad - la versión más pequeña y de una sola función de este patrón.
- Cómo Funciona la Observabilidad para Aplicaciones LLM - dónde encaja el registro estructurado en relación con el rastreo y la alerta.
- Instrumentación de Bucles de Agente con Rastreo OpenTelemetry - unir estas líneas de registro a tramos de rastreo a través del ID de solicitud.
- Integración de Paneles de Uso y Costos de Claude con Datadog - envío de los campos de tokens de este esquema a un panel de costos.
- Mejores Prácticas de Observabilidad - una lista de verificación consolidada para esta sección.
Versiones de Stack: Escrito contra la línea de modelos de Claude actual a partir de ~junio de 2026 - Claude Fable 5, Claude Opus 4.8, Claude Sonnet 5 (el predeterminado), y Claude Haiku 4.5 - y el SDK oficial de Python
anthropic(última versión 0.x). Los nombres de modelos, precios y versiones de SDK cambian rápidamente; verifica los detalles actuales en platform.claude.com/docs antes de confiar en ellos.