Módulo 6: Estado compartido y el patrón blackboard
Anatomía del Blackboard
Descripción
El Módulo 5 construyó el paquete de handoff: un agente arma, a mano, un mensaje dirigido a otro —quién envía, a quién, la tarea, el payload mínimo—. Cada paquete es una decisión de diseño: hay que pensar, en el momento de escribirlo, exactamente qué necesita el receptor.
Esta lección construye la pieza que reemplaza esa decisión por una más simple: Blackboard, una
estructura de datos con campos fijos —member, room, tier, hours, price_cents,
booking_id— que cualquier agente de la corrida puede leer o escribir, más un log: la lista
completa de quién escribió qué, en qué orden. Vas a crear un Blackboard vacío, hacer el primer
write de la guía —el supervisor anotando quién es el socio— y ver, con tus propios ojos, cómo el
objeto y su log cambian con esa sola escritura.
Conexión con el módulo
Esta lección es la base formal sobre la que se apoyan las cinco lecciones que siguen: Blackboard
y WriteLogEntry, tal como se construyen aquí, se reusan sin cambios en el resto del módulo. La
lección 03 los usa por primera vez con un especialista real (booking_agent) escribiendo resultados
de una tool ejecutada; la lección 05 vuelve sobre el log para auditar una corrida completa.
Analogía: la pizarra en blanco, antes de que nadie escriba nada
Retoma la sala de guardia de la lección 01. Antes de que cualquier médico escriba algo, la pizarra existe igual: tiene sus columnas fijas —cama, diagnóstico, medicación— aunque estén vacías. Esa estructura fija es lo primero que hace falta: sin columnas definidas, cada médico escribiría lo que se le ocurriera, en el lugar que se le ocurriera, y la pizarra dejaría de ser útil para nadie. La primera escritura —"cama 4, ingresó con fiebre"— es el momento en que la pizarra empieza a servir para algo. Esta lección construye exactamente eso: las columnas fijas, y la primera anotación.
Ejemplo trabajado: Blackboard, WriteLogEntry, y el primer write
from dataclasses import dataclass, field
import itertools
WRITE_SEQ = itertools.count(1)
@dataclass
class WriteLogEntry:
"""Una entrada del log: quién escribió QUÉ campo, con QUÉ valor, en
qué SECUENCIA. `seq` viene de un contador (itertools.count) -- nunca
de datetime.now(), la misma disciplina de IDs deterministas de toda
esta guía."""
seq: int
writer: str
field: str
value: object
@dataclass
class Blackboard:
"""El estado compartido de UNA corrida del sistema de Reservo. Los
seis primeros campos son los HECHOS que los agentes leen y escriben;
`log` es el registro de cada escritura, en orden."""
member: str | None = None
room: str | None = None
tier: str | None = None
hours: int | None = None
price_cents: int | None = None
booking_id: int | None = None
log: list = field(default_factory=list)
def write(self, writer, **fields):
"""Escribe uno o más campos a la vez, y registra cada uno en el
log con su autor. `writer` es el NOMBRE del agente que escribe
-- un string, no una referencia al objeto agente -- porque el
log es para leer humanos, no para que otro código lo consuma."""
for key, value in fields.items():
setattr(self, key, value)
self.log.append(
WriteLogEntry(seq=next(WRITE_SEQ), writer=writer, field=key, value=value)
)
print("--- un Blackboard recién creado: todos los campos vacíos, el log vacío ---")
bb = Blackboard()
print(bb)
print()
print("--- el supervisor escribe el primer dato: quién es el socio ---")
bb.write("supervisor", member="Ana")
print(bb)
print()
print("--- el log completo, con un solo write hasta ahora ---")
for entry in bb.log:
print(f" #{entry.seq} {entry.writer:<12} escribió {entry.field}={entry.value!r}")
Qué esperar:
--- un Blackboard recién creado: todos los campos vacíos, el log vacío ---
Blackboard(member=None, room=None, tier=None, hours=None, price_cents=None, booking_id=None, log=[])
--- el supervisor escribe el primer dato: quién es el socio ---
Blackboard(member='Ana', room=None, tier=None, hours=None, price_cents=None, booking_id=None, log=[WriteLogEntry(seq=1, writer='supervisor', field='member', value='Ana')])
--- el log completo, con un solo write hasta ahora ---
#1 supervisor escribió member='Ana'
Fíjate en dos cosas del repr de bb después del write: (1) member cambió de None a
'Ana' directamente en el objeto —el Blackboard es mutable, a diferencia de casi todo lo que
construiste hasta ahora en esta guía, donde cada estructura (RoutingDecision, PipelineStage,
SubTask) se creaba una vez y no volvía a cambiar—. (2) log ahora tiene una entrada — el
write no solo cambió el valor, también dejó un rastro de que ese cambio ocurrió, quién lo hizo, y
en qué secuencia.
Por qué el write no recibe un destinatario
Compara la firma de bb.write("supervisor", member="Ana") con la del paquete de handoff del Módulo
5, que necesitaba un to explícito —a quién iba dirigido el mensaje—. Blackboard.write no tiene
ningún parámetro parecido. Esa ausencia es exactamente el punto central del patrón: el supervisor no
sabe, en el momento de escribir member, qué agente lo va a leer después —podría ser
policy_agent, podría ser pricing_agent, podría ser los dos, podría ser ninguno en una corrida
donde nadie necesita ese dato—. Escribe el hecho, y punto. Quien lo necesite, lo lee cuando le toca.
Esta ausencia de destinatario es también la raíz del trade-off completo del módulo: sin un to, no
hay forma de limitar de antemano quién puede leer member — cualquier agente con una referencia al
mismo objeto bb puede hacer bb.member en cualquier momento de la corrida. La lección 06 mide
exactamente cuánto "ver todo" cuesta, comparado con un paquete diseñado a medida.
Los seis campos: por qué estos y no otros
Blackboard no intenta modelar cualquier estado posible de Reservo — tiene exactamente los seis
campos que las lecciones 03, 04 y 07 necesitan para una corrida completa:
member -- lo escribe el supervisor, al recibir la petición
room -- lo escribe booking_agent, tras cotizar o reservar
tier -- lo escribe booking_agent, tras cotizar o reservar
hours -- lo escribe booking_agent, tras cotizar o reservar
price_cents -- lo escribe booking_agent, tras cotizar
booking_id -- lo escribe booking_agent, tras reservar (puede quedar en None si aún no reservó)
Ningún campo tiene un dueño exclusivo en el sentido de "solo este agente puede escribirlo" — nada en
Blackboard.write lo impide. Pero en la práctica, en este módulo, booking_agent es el único que
produce cotizaciones y reservas reales, así que es el único escritor natural de los últimos cinco
campos. Esa distinción —quién puede escribir contra quién de hecho escribe— importa para la
lección 06, cuando se nombra la superficie de un agente que escribe algo que no le correspondería.
Errores comunes
-
Pensar que
Blackboard()sin argumentos falla porque los campos no tienen valor. Todos los campos tienen un valor por defecto (None, o[]paralog, víadefault_factory) — unBlackboardrecién creado es válido, simplemente vacío. No hace falta pasarle nada para construirlo. -
Usar
field(el parámetro dewrite) yfield(la función dedataclasses) como si fueran lo mismo. No lo son — dentro dewrite,**fieldscaptura los argumentos nombrados que llegaron (member="Ana", por ejemplo) en un diccionario local llamadofields; la funciónfield()importada dedataclassessolo se usa una vez, para declarar eldefault_factorydelog. Son dos cosas con nombres parecidos, en espacios distintos. -
Olvidar que
setattr(self, key, value)no valida quekeysea uno de los seis campos declarados. Siwriterecibe un nombre de campo con un typo —roomescrito comorooom— Python lo acepta sin quejarse: crea un atributo nuevo en la instancia, que ni siquiera aparece en elreprdel dataclass (porque no es un campo declarado). El Ejercicio 3 de esta lección lo confirma, ejecutado. -
Pensar que
bb.loges la única forma de saber el estado actual. No —bb.member,bb.room, etc. siempre reflejan el último valor escrito, sin tener que recorrer el log. El log sirve para reconstruir la historia, no para leer el presente — la lección 05 profundiza en esta distinción. -
Confundir
BlackboardconSPECIALISTS. Son estructuras completamente distintas para preguntas distintas:SPECIALISTS(Módulo 2) responde "¿qué tools tiene este agente?" y no cambia durante una corrida;Blackboardresponde "¿qué hechos sabemos hasta ahora?" y cambia con cadawrite. Un sistema real usa las dos a la vez, sin que se pisen.
Ejercicios
Ejercicio 1: Confirma tu propia ejecución (Fácil)
Ejecuta el ejemplo trabajado de esta lección tú mismo y confirma que tu salida coincide,
línea por línea, con el bloque "Qué esperar". Presta atención especial al seq=1 de la primera
entrada del log — si corriste algo más antes en el mismo intérprete, WRITE_SEQ puede no arrancar
en 1.
Ver solución
No hay una única "solución de código" para este ejercicio — es una verificación: si tu salida
coincide exactamente con el "Qué esperar" del ejemplo trabajado, tu WRITE_SEQ arrancó limpio y el
write corrió sin desvíos.
Ejercicio 2: Un segundo write, de un escritor distinto (Medio)
Sobre el mismo bb del ejemplo trabajado, ejecuta un segundo write — esta vez de
"booking_agent", escribiendo room="Focus" y tier="pro" a la vez (dos campos en el mismo
write). Imprime el Blackboard completo y el log con las dos entradas nuevas.
Ver solución
bb.write("booking_agent", room="Focus", tier="pro")
print(bb)
print()
for entry in bb.log:
print(f" #{entry.seq} {entry.writer:<14} escribió {entry.field}={entry.value!r}")
Salida esperada:
Blackboard(member='Ana', room='Focus', tier='pro', hours=None, price_cents=None, booking_id=None, log=[WriteLogEntry(seq=1, writer='supervisor', field='member', value='Ana'), WriteLogEntry(seq=2, writer='booking_agent', field='room', value='Focus'), WriteLogEntry(seq=3, writer='booking_agent', field='tier', value='pro')])
#1 supervisor escribió member='Ana'
#2 booking_agent escribió room='Focus'
#3 booking_agent escribió tier='pro'
Explicación: un solo write(writer, **fields) con dos campos nombrados produce dos entradas
en el log, no una — porque el for key, value in fields.items() de la lección itera cada campo por
separado, aunque hayan llegado en la misma llamada. seq sigue siendo consecutivo (2, 3) sin
importar que ambos hayan sido, en los hechos, "la misma escritura" desde el punto de vista de quien
la hizo.
Ejercicio 3: El typo silencioso (Difícil)
En un proceso nuevo (para que WRITE_SEQ arranque en 1), ejecuta
bb.write("booking_agent", rooom="Studio") —con el typo rooom, a propósito— sobre un Blackboard
recién creado. (a) ¿Qué le pasa a bb.room? (b) ¿Aparece rooom en el repr de bb? (c) ¿Por qué
esto es más peligroso que el KeyError que viste en el Módulo 2 cuando un target no existía en
SPECIALISTS?
Ver solución
bb_typo = Blackboard()
bb_typo.write("booking_agent", rooom="Studio")
print("bb_typo.room:", bb_typo.room)
print("bb_typo.rooom:", bb_typo.rooom)
print(bb_typo)
Salida real:
bb_typo.room: None
bb_typo.rooom: Studio
Blackboard(member=None, room=None, tier=None, hours=None, price_cents=None, booking_id=None, log=[WriteLogEntry(seq=1, writer='booking_agent', field='rooom', value='Studio')])
(a) bb_typo.room sigue siendo None — el typo nunca tocó el campo real.
(b) No — rooom no aparece en el repr() del Blackboard, porque el repr que genera
@dataclass solo imprime los campos declarados en la clase; rooom es un atributo que
setattr creó en la instancia por fuera de esa declaración, así que queda invisible para cualquiera
que solo mire print(bb_typo). Solo aparece si revisas el log directamente, o si haces
bb_typo.rooom a mano sabiendo que existe.
(c) Es más peligroso porque es un error doblemente silencioso: el KeyError del Módulo 2
detiene el programa de inmediato, con un mensaje claro (KeyError('shipping_agent')), en la línea
exacta donde ocurrió. El typo de write no detiene nada — el programa sigue corriendo, room queda
en None como si nadie lo hubiera escrito nunca, y el dato equivocado (rooom='Studio') queda
enterrado en el log, sin aparecer siquiera en el repr del objeto. Un especialista que después lea
bb.room para armar su tarea recibiría None, sin ningún indicio de que la causa fue un typo en
otro agente, minutos o pasos antes.
Resumen y siguiente paso
Blackboardtiene seis campos fijos —member,room,tier,hours,price_cents,booking_id— más unlogdeWriteLogEntry, conseq(un contador determinista, nuncadatetime.now()),writer,fieldyvalue.write(writer, **fields)no recibe ningún destinatario — a diferencia del paquete de handoff del Módulo 5. Esa ausencia es la raíz de todo el trade-off del módulo: coordinar es más simple, pero cualquier agente con acceso al objeto ve todos los campos, sin restricción.- Un
writecon varios campos a la vez produce una entrada de log por campo, no una sola — el log registra a nivel de campo individual, no a nivel de llamada. setattrsin validación acepta un nombre de campo con typo silenciosamente — un error que ni siquiera aparece en elreprdel objeto, más peligroso que unKeyErrorruidoso.
Siguiente lección: 03 — Escribiendo en el Blackboard. Corremos booking_agent de verdad, y
anclamos sus escrituras en el tool_result real de get_quote y book_room — no en el texto libre
del modelo.
Recursos adicionales
- Python —
dataclasses— El módulo detrás deBlackboardyWriteLogEntry, incluyendodefault_factorypara el campo mutablelog. - Python —
itertools.count— La fuente delseqdeterminista de cada entrada del log. - Anthropic — Multi-agent research system — Un sistema real donde el estado que un sub-agente produce queda disponible para otros sin que el primero sepa, de antemano, quién lo va a consumir.
- Python —
setattr— La función detrás deBlackboard.write, y por qué no valida nombres de atributo por sí sola.