Módulo 6: Estado compartido y el patrón blackboard
El log de escritura
Descripción
Las lecciones 03 y 04 usaron bb.log de pasada, para mostrar el orden en que las escrituras
ocurrieron. Esta lección se detiene en el log en sí: por qué existe, qué garantiza, y qué revela que
el estado actual del Blackboard —los seis campos con su último valor— no puede revelar por sí
solo. El caso central: Ana cambia de sala a mitad de la corrida. booking_agent cotiza Focus
primero, y después vuelve a cotizar Studio, corrigiendo lo que ya había escrito. El estado final del
Blackboard solo muestra Studio — pero el log conserva la corrección completa, con las dos
cotizaciones, quién las escribió, y en qué orden.
Sin el log, esa corrección desaparecería sin dejar rastro: cualquiera que mirara el Blackboard
después vería room='Studio' como si esa hubiera sido siempre la elección, sin ninguna forma de
saber que Focus estuvo ahí primero. Con el log, la corrección es un hecho auditable, no un detalle
perdido.
Conexión con el módulo
Esta lección extiende Blackboard de las lecciones 02-04 con history_of, un método nuevo que lee
el log filtrado por campo. No cambia write ni ninguna estructura anterior. La lección 07 vuelve a
citar el log completo de una corrida de cuatro roles; la lección 08 lo usa para diagnosticar el
escenario "mal hecho" del mini-proyecto.
Analogía: el historial clínico, no solo el diagnóstico de hoy
Una ficha médica bien llevada no solo dice "el paciente tiene X" — dice cuándo se diagnosticó X, qué
decía la ficha antes de ese diagnóstico, y quién lo escribió. Si un médico corrige un diagnóstico
anterior, esa corrección queda agregada a la ficha, no borra lo que había antes: un tratamiento
que ya se administró basado en el diagnóstico viejo sigue siendo parte de la historia real del
paciente, aunque hoy se sepa que ese diagnóstico estaba equivocado. El log del Blackboard funciona
igual: nunca borra una entrada anterior, solo agrega la corrección encima.
Ejemplo trabajado: Ana cambia de sala a mitad de la corrida
import concurrent.futures
import itertools
from dataclasses import dataclass, field
import reservo_tools as rt
def dispatch_parallel(tool_use_blocks, tools):
with concurrent.futures.ThreadPoolExecutor(max_workers=len(tool_use_blocks)) as pool:
futures = [pool.submit(tools[b["name"]], **b["input"]) for b in tool_use_blocks]
results = [f.result() for f in futures]
return [
{"type": "tool_result", "tool_use_id": b["id"], "content": str(r)}
for b, r in zip(tool_use_blocks, results)
]
def run_agent_parallel(question, model_script, tools, max_iterations=10):
messages = [{"role": "user", "content": question}]
for step in range(max_iterations):
turn = model_script[step]
messages.append({"role": "assistant", "content": turn["content"]})
if turn["stop_reason"] != "tool_use":
return turn, messages
tool_result_blocks = dispatch_parallel(turn["content"], tools)
messages.append({"role": "user", "content": tool_result_blocks})
raise RuntimeError(f"max_iterations alcanzado ({max_iterations})")
SPECIALISTS = {
"booking_agent": {
"tools": {
"list_rooms": rt.list_rooms, "get_quote": rt.get_quote,
"book_room": rt.book_room, "cancel_booking": rt.cancel_booking,
},
},
}
def run_specialist(name, task, model_script):
tools = SPECIALISTS[name]["tools"]
return run_agent_parallel(task, model_script, tools)
def all_tool_results(history):
import ast
results = []
for m in history:
if isinstance(m["content"], list):
for b in m["content"]:
if b["type"] == "tool_result":
try:
results.append(ast.literal_eval(b["content"]))
except (ValueError, SyntaxError):
results.append(b["content"])
return results
WRITE_SEQ = itertools.count(1)
@dataclass
class WriteLogEntry:
seq: int
writer: str
field: str
value: object
@dataclass
class Blackboard:
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):
for key, value in fields.items():
setattr(self, key, value)
self.log.append(
WriteLogEntry(seq=next(WRITE_SEQ), writer=writer, field=key, value=value)
)
def history_of(self, field_name):
"""Todas las entradas del log para UN campo, en orden -- deja ver
cómo cambió ese valor a lo largo de la corrida, no solo su
estado actual."""
return [e for e in self.log if e.field == field_name]
bb = Blackboard()
bb.write("supervisor", member="Ana")
print("--- booking_agent cotiza Focus pro 3h y escribe ---")
bb.write("booking_agent", room="Focus", tier="pro", hours=3, price_cents=6000)
print(f"room={bb.room!r} tier={bb.tier!r} hours={bb.hours!r} price_cents={bb.price_cents!r}")
print()
print("--- Ana cambia de sala a mitad de la corrida: 'mejor Studio, misma duración' ---")
print("booking_agent vuelve a cotizar y vuelve a escribir -- MISMOS campos, valores nuevos")
bb.write("booking_agent", room="Studio", tier="pro", hours=3, price_cents=9600)
print(f"room={bb.room!r} tier={bb.tier!r} hours={bb.hours!r} price_cents={bb.price_cents!r}")
print()
print("--- booking_agent confirma la reserva de Studio ---")
model_script_book_studio = [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01", "name": "book_room",
"input": {"room": "Studio", "tier": "pro", "hours": 3, "member": "Ana"}}]},
{"stop_reason": "end_turn", "content": [
{"type": "text", "text": "Reservé Studio pro 3h para Ana (confirmación #1), 9600 centavos."}]},
]
_, history_book = run_specialist("booking_agent", "Reserva Studio pro 3h para Ana.", model_script_book_studio)
(booking_result,) = all_tool_results(history_book)
bb.write("booking_agent", booking_id=booking_result["booking_id"])
print()
print("--- estado FINAL del Blackboard: solo el último valor de cada campo ---")
print(f"room={bb.room!r} tier={bb.tier!r} hours={bb.hours!r} price_cents={bb.price_cents!r} booking_id={bb.booking_id!r}")
print()
print("--- el log completo: la CORRECCIÓN queda visible, aunque el estado actual ya la tapó ---")
for entry in bb.log:
print(f" #{entry.seq} {entry.writer:<14} escribió {entry.field}={entry.value!r}")
print()
print("--- auditando UN campo con history_of: cómo cambió price_cents durante la corrida ---")
for entry in bb.history_of("price_cents"):
print(f" #{entry.seq} {entry.writer} puso price_cents={entry.value}")
Qué esperar:
--- booking_agent cotiza Focus pro 3h y escribe ---
room='Focus' tier='pro' hours=3 price_cents=6000
--- Ana cambia de sala a mitad de la corrida: 'mejor Studio, misma duración' ---
booking_agent vuelve a cotizar y vuelve a escribir -- MISMOS campos, valores nuevos
room='Studio' tier='pro' hours=3 price_cents=9600
--- booking_agent confirma la reserva de Studio ---
--- estado FINAL del Blackboard: solo el último valor de cada campo ---
room='Studio' tier='pro' hours=3 price_cents=9600 booking_id=1
--- el log completo: la CORRECCIÓN queda visible, aunque el estado actual ya la tapó ---
#1 supervisor escribió member='Ana'
#2 booking_agent escribió room='Focus'
#3 booking_agent escribió tier='pro'
#4 booking_agent escribió hours=3
#5 booking_agent escribió price_cents=6000
#6 booking_agent escribió room='Studio'
#7 booking_agent escribió tier='pro'
#8 booking_agent escribió hours=3
#9 booking_agent escribió price_cents=9600
#10 booking_agent escribió booking_id=1
--- auditando UN campo con history_of: cómo cambió price_cents durante la corrida ---
#5 booking_agent puso price_cents=6000
#9 booking_agent puso price_cents=9600
Lee las dos secciones finales con cuidado. El estado (bb.room, bb.price_cents, etc.) solo te
dice dónde terminó todo: Studio, 9600 centavos. Si alguien te preguntara "¿por qué el precio final es
9600 y no 6000?", el estado solo no te lo puede responder — necesitas el log. Con
history_of("price_cents"), la respuesta queda completa: booking_agent escribió 6000 primero
(#5), y el mismo booking_agent lo corrigió a 9600 después (#9). Ninguna otra escritura tocó
ese campo en el medio — la corrección fue del mismo agente, no una interferencia de otro.
Por qué el log nunca borra, solo agrega
Blackboard.write no tiene ningún mecanismo para "reemplazar" una entrada del log — cada llamada
agrega entradas nuevas, sin tocar las anteriores. Esta es una decisión de diseño deliberada: si
write sobrescribiera la entrada anterior de price_cents en vez de agregar una nueva, la
corrección de Ana desaparecería del historial exactamente igual que desaparece del estado —
perdiendo la única fuente que permite reconstruir, después, que hubo una corrección en absoluto.
El costo de esta decisión es que el log crece sin límite durante una corrida — cada write
suma entradas, nunca las reduce. Para una corrida de una sola petición de Reservo, ese crecimiento es
trivial (decenas de entradas, no miles). Un sistema que corriera muchísimas peticiones sobre el
mismo proceso, sin nunca reiniciar el Blackboard, sí necesitaría pensar en esto — pero esta guía
nunca reusa un Blackboard entre corridas (la lección 08 lo confirma con un caso donde reusarlo
mal es exactamente el error).
Errores comunes
-
Pensar que
bb.price_centsdespués de la corrección todavía vale6000. No — el estado siempre refleja la escritura más reciente; solo el log, no el estado, conserva el valor anterior. Confundir estos dos es el error más común al trabajar con unBlackboard. -
Usar
history_ofpara tomar decisiones en tiempo de ejecución. El propósito del log es auditar después de que la corrida terminó (o a mitad de ella, para depurar) — ningún especialista de este módulo consultahistory_ofpara decidir qué hacer; todos leen el campo directamente (bb.price_cents), que siempre da el valor vigente. -
Olvidar que dos
writecon el mismo campo, del mismo agente, no son un error. A diferencia de escribir a un campo con unwriterdistinto sin ninguna razón clara (una señal de posible conflicto, que la lección 06 toca), que el mismo agente corrija su propio dato en la misma corrida es exactamente el caso de esta lección — legítimo, y el log lo documenta sin ambigüedad. -
Pensar que hace falta un campo
corrected=Trueo algo parecido para marcar una corrección. No — el log ya lo deja implícito: sihistory_of(campo)tiene más de una entrada, hubo una corrección; el número de entradas y su orden son toda la información que se necesita. -
Ejecutar este ejemplo en un proceso que ya tenía reservas. Si
book_roomno devuelvebooking_id: 1, el intérprete ya corrióbook_roomantes en la misma sesión.
Ejercicios
Ejercicio 1: Confirma tu propia ejecución (Fácil)
Ejecuta el ejemplo trabajado tú mismo y confirma que history_of("room") muestra las dos entradas
(Focus, después Studio), en ese orden, con los números de secuencia correctos.
Ver solución
for entry in bb.history_of("room"):
print(f" #{entry.seq} {entry.writer} puso room={entry.value!r}")
Salida esperada:
#2 booking_agent puso room='Focus'
#6 booking_agent puso room='Studio'
Si tu salida coincide, confirmaste que el log conserva ambas escrituras de room en el orden
correcto — la misma verificación que ya hiciste con price_cents en el ejemplo trabajado.
Ejercicio 2: ¿Cuántos campos NO tuvieron ninguna corrección? (Medio)
Sobre el Blackboard final del ejemplo trabajado, cuenta cuántos de los seis campos tienen
exactamente una entrada en su history_of (sin ninguna corrección), contra cuántos tienen más
de una.
Ver solución
ALL_FIELDS_L05 = ["member", "room", "tier", "hours", "price_cents", "booking_id"]
for f in ALL_FIELDS_L05:
n = len(bb.history_of(f))
print(f"{f:12} -> {n} escritura(s)")
Salida esperada:
member -> 1 escritura(s)
room -> 2 escritura(s)
tier -> 2 escritura(s)
hours -> 2 escritura(s)
price_cents -> 2 escritura(s)
booking_id -> 1 escritura(s)
Explicación: member y booking_id se escribieron una sola vez cada uno (el socio no cambió,
y la reserva se confirmó una sola vez, al final, ya sobre Studio). room, tier, hours y
price_cents tienen dos entradas cada uno porque los cuatro se volvieron a escribir juntos, en el
mismo write, cuando Ana cambió de sala — confirma lo que ya viste en el Ejercicio 2 de la lección
02: un write con varios campos deja una entrada de log por cada campo, no una sola.
Ejercicio 3: Un campo que "cambia" al mismo valor (Difícil)
Ejecuta bb.write("booking_agent", tier="pro") una vez más, sobre el bb final del ejemplo
trabajado —con el mismo valor "pro" que ya tenía—. ¿El log agrega una entrada nueva? ¿Debería
Blackboard.write, tal como está construido en esta lección, distinguir entre "escribir un valor
distinto" y "volver a escribir el mismo valor"?
Ver solución
n_antes = len(bb.history_of("tier"))
bb.write("booking_agent", tier="pro")
n_despues = len(bb.history_of("tier"))
print(f"entradas de 'tier' antes: {n_antes}, después: {n_despues}")
for entry in bb.history_of("tier"):
print(f" #{entry.seq} {entry.writer} puso tier={entry.value!r}")
Salida esperada:
entradas de 'tier' antes: 2, después: 3
#3 booking_agent puso tier='pro'
#7 booking_agent puso tier='pro'
#11 booking_agent puso tier='pro'
Explicación: sí, el log agrega una tercera entrada, aunque el valor no haya cambiado —
Blackboard.write, tal como está construido en esta lección, registra cada llamada a write
con ese campo, sin comparar el valor nuevo contra el anterior. Esto es una decisión de diseño
válida, no un bug: para los fines de esta guía (auditar quién tocó cada campo, y cuándo), que
alguien "reafirme" un valor sin cambiarlo también es información útil — por ejemplo, confirma que
booking_agent volvió a pasar por ese campo en un segundo write, aunque el resultado haya sido el
mismo. Una versión más estricta de write podría comparar getattr(self, key) == value antes de
agregar una entrada, pero eso cambiaría el propósito del log: de "cada vez que alguien escribió" a
"cada vez que algo realmente cambió" — una decisión distinta, que esta lección no toma.
Resumen y siguiente paso
- El estado del
Blackboard(bb.room,bb.price_cents, etc.) siempre refleja la escritura más reciente; el log conserva la historia completa, incluidas las correcciones que el estado ya tapó. history_of(field_name)filtra el log por un solo campo, dejando ver cómo cambió ese valor a lo largo de la corrida — sin ese método, reconstruir la historia de un campo requeriría filtrarbb.loga mano cada vez.- Un
writenunca borra ni reemplaza entradas anteriores del log — solo agrega. Esa es la propiedad que hace posible auditar correcciones después de que ocurrieron. - El log registra cada llamada a
write, incluso si el valor nuevo es igual al anterior — una decisión de diseño explícita, no un descuido.
Siguiente lección: 06 — El trade-off medido: visibilidad contra aislamiento. Contamos, con
números reales, cuánto del Blackboard completo ve cada agente, comparado con lo mínimo que un
paquete de handoff a medida necesitaría.
Recursos adicionales
- Python —
dataclasses— El módulo detrás deWriteLogEntry, conhistory_offiltrando por el campofieldde cada entrada. - Anthropic — Multi-agent research system — Un sistema real donde poder reconstruir qué sub-agente produjo cada dato, y cuándo, es parte de cómo se depuran resultados inesperados.
- Python — List comprehensions — El mecanismo detrás de
history_of, filtrandoself.logpor un predicado simple. - Python — Igualdad de valores (
==) — La comparación que una versión más estricta dewritepodría usar para distinguir una reafirmación de un cambio real, discutida en el Ejercicio 3.