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.
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.
import jsonimport loggingimport timeimport uuidimport anthropiclogging.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_textdef 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_entry que aplica un esquema, llamada tanto desde las rutas de éxito como de error.
Omitir los campos None evita que las líneas de registro de errores contengan campos de respuesta/token vacíos engañosos.
request_id generado antes de la llamada, por lo que existe incluso cuando la llamada falla.
cache_read_input_tokens capturado con getattr ya 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_count adicional, que muestra que el esquema se extiende en lugar de reemplazarse por tipo de llamada.
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 de json.dumps a 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 (no print) 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 como status:error o una agregación sobre input_tokens funciona independientemente del resultado.
Prefiere logging.Logger sobre print incluso 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 de logging.Formatter que codifica en JSON el LogRecord completo evita la doble serialización y mantiene las trazas de pila intactas en caso de errores.
Redacta o trunca los campos prompt/response antes 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.
Construir el diccionario de registro en línea en cada sitio de llamada. Sin una función log_entry compartida, 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 usage antes de verificar si hay una excepción. Si la llamada falla, response nunca se asigna, y hacer referencia a response.usage en un bloque finally genera un error NameError. Solución: calcula los campos de tokens solo dentro de la rama de éxito y registra None para ellos en la ruta de error.
La falta de cache_read_input_tokens rompe 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 con getattr(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 de logging.Logger, incluso para un script rápido.
¿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, y status.
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) raise
No 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.
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.
Revisado por Chris St. John·Última actualización: 13 jul 2026