LLM
Primitivas LLM
El LLM es el motor de razonamiento, intercambiable como un driver de base de datos. Cuatro operaciones, gateadas por la capacidad llm (auto-otorgada en run, exigida bajo serve):
-- Doc example: LLM operations. The four ops return TEXT and need a real provider,
-- so the doctest verifies the offline/online check; the ops are shown in `summarize`.
intent: "doc example: LLM operations"
require llm
task summarize(report)
when llm_available()
give reason about report with context = "be concise" -- needs a provider
otherwise
give "(LLM offline)"
print("summarize(\"a report\") → " + summarize("a report")) -- offline → (LLM offline)
test "llm_available() is a bool — branch on it instead of guessing"
assert_eq(type_of(llm_available()), "bool")
-- llm_stream(prompt, context, on_chunk): on_chunk receives each fragment as it is
-- produced; the call returns the full text. Offline it returns the placeholder
-- WITHOUT ever invoking on_chunk (placeholders are not answers).
task on_tok(t)
print("chunk: " + t)
test "llm_stream: offline → placeholder, on_chunk untouched; online → text"
when llm_available()
assert_eq(type_of(llm_stream("Reply with one word: pong", "", on_tok)), "text")
otherwise
assert_eq(llm_stream("q", "", on_tok), "[no llm provider]")
-- Offline, each op returns a descriptive placeholder — text, never an answer. They are NOT
-- inputs: lineage() records only what a real model returned.
test "offline placeholders: text you can tell apart, and never recorded as an input"
when not llm_available()
let ticket be "my order arrived broken"
assert_eq(reason about ticket, "[reasoning about: my order arrived broken]")
assert_eq(decide between ["refund", "replace"] given ticket, "[decision pending]")
assert_eq(analyze ticket for "tone", "[analysis of: tone]")
assert_eq(generate "reply email" given ticket with tone = "empathetic", "[generated: reply email]")
assert_eq(llm_step("q", [], ""), {"kind": "final", "text": "[no llm provider]", "tokens": 0})
assert_eq(llm_usage(), 0)
assert_eq(lineage(), [])Las cuatro operaciones§
require llm
let analysis be analyze sales_data for "trends and anomalies"
let action be decide between ["refund", "replace", "escalate"] given ticket
let email be generate "response email" given complaint with tone = "empathetic"
let insight be reason about problem with context = background_data
Llevan un sujeto: reason about X, decide between [...] given X, analyze X for "...", generate "..." given X. (Un reason "literal" pelado también funciona.)
Decisiones validadas§
El resultado de un decide between [...] debe ser exactamente una de las opciones. Synsema lo hace cumplir en capas: en los providers de red la elección se fuerza a nivel API (una tool interna cuyo schema restringe la respuesta a tus opciones — cero reintentos en el caso común); la respuesta se normaliza (mayúsculas, puntuación, contención por palabra completa: "La respuesta es AZUL" → AZUL); si aún no matchea, un reintento con feedback; y recién entonces avisa (stderr, una vez) y devuelve la respuesta cruda — la cadena nunca se rompe, pero nunca en silencio.
Modo offline§
Sin un proveedor configurado, las ops devuelven placeholders descriptivos (p. ej. decide → "[decision pending]"), así los programas siguen corriendo — la cadena nunca se rompe. Pero nunca es en silencio: la primera op LLM que cae a placeholder imprime un aviso único por stderr apuntándote a synsema llm status para el diagnóstico exacto (qué variable falta, y dónde). Los placeholders son [reasoning about: …], [decision pending] (que no es una de tus opciones — el código que ramifica sobre un decide tiene que contemplarlo), [analysis of: …] y [generated: …]; llm_step y llm_stream devuelven [no llm provider]. Ramificá según llm_available() en vez de adivinar:
require llm
task summarize(report)
let s be "(LLM offline)"
when llm_available()
set s to reason about report
give s
Adentro de parallel_map y de un agente lanzado con spawn (motor v0.6.45+), reason, decide, analyze y generate usan el mismo provider que el código que los lanzó. Antes, bajo synsema run cada item de parallel_map y cada agente corrían en un intérprete nuevo sin providers y recibían los placeholders offline, mientras el mismo código en el hilo principal recibía respuestas reales. Bajo serve, los agentes ya estaban cableados. El presupuesto del proceso de abajo vale adentro de ellos también.
Metering y presupuesto — llm_usage(), SYNSEMA_LLM_BUDGET§
Toda llamada real al provider se mide. llm_usage() → número de tokens LLM (input + output) consumidos por este proceso hasta ahora — introspección, sin capability (como llm_available()); 0 offline o antes de la primera llamada. Un contador por proceso, compartido también bajo serve. Es monotónico: las ventanas de tiempo son política de tu programa, no estado del runtime.
El host puede fijar un presupuesto duro de tokens con SYNSEMA_LLM_BUDGET (entero positivo, resuelto environ del proceso > .env; inválido o 0 → sin presupuesto, con aviso). Al llegar al tope, toda op LLM degrada al marker [llm budget exceeded: used N of M tokens] — sin error, sin llamada de red, un solo aviso por stderr por proceso. Una op LLM jamás rompe la cadena del agente (misma filosofía que los placeholders offline), así que el código que deba frenar al agotarse el presupuesto ramifica sobre el marker o sobre llm_usage():
require llm
task ask_once(question)
let before be llm_usage()
let answer be reason about question
when contains(answer, "llm budget exceeded")
give "(budget exhausted — not retrying)"
print("this call cost " + text(llm_usage() - before) + " tokens")
give answer
Por identidad, bajo serve (engine v0.6.20+). SYNSEMA_LLM_BUDGET_PER_IDENTITY="alice=200000,agent-7=1500000" acota los tokens que cada identidad autenticada puede consumir en este proceso — la identidad que devuelve tu task de auth with (id, sub o keyid; la misma que usan spend y los rate limits), en toda clase de ruta, stream y socket incluidas. El contador es del proceso (uno para todos los workers y los hilos de stream) y vive al lado del global. Cuando una identidad cruza su línea, sus ops devuelven [llm budget exceeded for identity alice: used N of M tokens] — el mismo contrato del marker, un aviso por stderr por identidad — mientras las demás identidades y los requests anónimos siguen. Los requests sin identidad (sin auth, run, cron) solo responden a SYNSEMA_LLM_BUDGET. Pensá en números reales: una tarea común gasta 20 000 tokens o más por llamada, así que los techos por identidad son de cientos de miles. Las cuatro operaciones no cambiaron — esto es un techo del host alrededor del provider, como SYNSEMA_SPEND_CEILING; una plataforma que necesite que la cuenta sobreviva un reinicio lleva su propio ledger y lee llm_usage().
Las respuestas del modelo son entradas — linaje (v0.6.29+)§
El engine registra la respuesta que devuelve un modelo real a reason, analyze y generate como una entrada del programa: lineage() la lista con source llm, what = <op> prompt sha256-salted:<hex> (reason prompt …, analyze prompt …: un compromiso con sal del prompt, nunca el prompt), el sha256 del texto que devolvió el modelo y encoding: "text"; receipt() la publica entre sus inputs. En v0.6.30 una respuesta de decide no se registra, y un placeholder offline nunca es una entrada. Para qué sirve esa lista, y cómo revelar un prompt después, está en Observabilidad § Linaje.
Datos privados a un modelo§
Motor v0.6.46+, con --labels. Hasta v0.6.45 cualquier valor privado en reason, analyze, decide, generate, judge, llm_step o llm_stream era un label_violation. Ahora puede llegar a un modelo que el host permite, y la respuesta sigue privada. Dos lados, los dos obligatorios:
1. El programa declara de quién son los datos que puede mandar: require llm("customers"). Un require llm desnudo sigue significando sólo datos públicos. require llm("P") también cubre las llamadas con datos públicos (antes de v0.6.46 rompía toda operación de LLM). 2. El host lo permite por principal y por proveedor, en su techo:
| Ítem del techo | Deja que los datos privados de customers vayan a |
|---|---|
llm=customers@api.anthropic.com | el proveedor cuyo host es api.anthropic.com |
llm=customers@127.0.0.1 | un endpoint configurado con SYNSEMA_LLM_BASE_URL=http://127.0.0.1:8080/v1 (el host, sin el puerto) |
llm=customers@local | un modelo en el proceso (SYNSEMA_LLM_PROVIDER=local); un proveedor sin configurar, offline, también cuenta como local |
llm=customers@* | cualquier proveedor — escrito a propósito |
llm=customers, llm=*, llm | ningún dato privado: un ítem sin @proveedor nunca lo permite |
llm=customers@embedder | en @synsema/wasm, el hook llm del embebedor |
El host que compara el chequeo es el del proveedor: anthropic → api.anthropic.com, openai → api.openai.com, deepseek → api.deepseek.com, minimax → api.minimax.io, local → local; con SYNSEMA_LLM_BASE_URL puesto, el host de esa URL. El programa nunca nombra al proveedor — lo elige el host, y el chequeo sigue al proveedor de cada llamada. Un ítem con scope también permite las llamadas con datos públicos, al proveedor que esté configurado.
synsema run --labels --cap-set "stdout,llm=customers@api.anthropic.com,declassify=customers" app.syn
require llm("customers")
let note be private("Ana Perez, order 3141", "customers")
let summary be generate "a one-line summary" given note
print(summary) -- private(customers)
print(declassify(summary, "the summary goes back to the same customer"))
Se rechaza, y no se manda nada, cuando el techo no tiene un ítem que coincida, el programa no tiene require llm("customers"), o no hay techo:
label_violation: reason would send data private to (customers) to the model; the host allows that per principal and provider in its ceiling (`--cap-set llm=<principal>@<provider host>`, `@local` for a model in the process, `@embedder` in wasm) and the program declares `require llm("<principal>")`. Otherwise declassify(<that value>, "<why the model may see it>") the part the model may see
Bajo una rama privada la llamada se rechaza antes que nada (reason called under private control flow …) y, desde v0.6.46, no escribe antes una entrada de auditoría "granted llm".
Si se permite, el prompt lleva el dato mismo (los pocos casos que pasaban antes de v0.6.46 mandaban el texto private(customers)), y:
- La respuesta lleva la etiqueta de lo que entró.
reason,analyze,decideygeneratedan un texto privado.llm_stepda el mapa entero privado, así que elegir una herramienta porstep["name"]necesita undeclassify, que tiene su propio ítem del techo — ver Tool calling. llm_stream: cada chunk lleva la etiqueta, yon_chunkcorre bajo la etiqueta de la respuesta, como el cuerpo de uneachsobre una lista privada (cuántos chunks hubo también es privado). Un efecto público ahí adentro —print, asignar una variable pública — es unlabel_violation. Juntalos en una variable privada:
require llm("customers")
let note be private("Ana", "customers")
let parts be private([], "customers")
task on_chunk(t)
set parts to parts + [t]
let full be llm_stream("Greet the customer", note, on_chunk)
- Auditoría: cada llamada escribe
llm("customers@api.anthropic.com")consource: "llm-data"yreason= la operación; una rechazada se escribe congranted: falseyreason: "<op>: not in the host ceiling or not required". El principal que figura es el conjunto declarado del programa. - Linaje: la respuesta a un prompt privado se hashea con la sal del prompt y se omite su largo —
what=generate prompt sha256-salted:<hex> response sha256-salted,bytes: 0. - El juez tiene su propio ítem,
judge=customers@…, y su propiorequire judge("customers")— ver Juez. Undecideque responde el juez (SYNSEMA_JUDGE_DECIDE=1) se chequea contrajudge=.
Lo que esto no da. El proveedor ve los datos: esto permite un proveedor en el que el host confía; no protege los datos de él. Lo que responde el modelo es privado pero no es confiable — un prompt inyectado le puede hacer decir cualquier cosa — así que lo que hay entre la respuesta y un sumidero es un declassify, con su propio techo. Y el tamaño del prompt se ve en la red, como en cualquier request https.
Streaming (llm_stream)§
llm_stream(prompt, context, on_chunk) genera con el proveedor configurado, invocando la task on_chunk con cada fragmento de texto a medida que se produce, y devuelve el texto completo. Mismo gate llm. Los proveedores de red streamean los text-deltas SSE de la API como chunks reales; el proveedor local embebido streamea token a token. (Con SYNSEMA_LLM_HTTP_STREAM=0 un proveedor de red cae a un chunk — la respuesta entera.)
require llm
task on_tok(t)
print(t)
let full be llm_stream("Explain CSP in one line", "", on_tok)
Offline devuelve "[no llm provider]" sin invocar on_chunk. Bajo una ruta SSE de serve compone con send — definí la task emisora dentro del bloque stream (send es un statement, no una función, y solo parsea ahí):
serve on 8080
route "GET /chat"
stream
task emit(tok)
send tok
let full be llm_stream("Count to ten in words", "", emit)
send full as "full"
Si on_chunk falla (p. ej. el send de un cliente desconectado), la generación corta temprano y el error propaga — recuperable con try/recover.
Configurá un proveedor en Config de proveedor; dejá que el modelo elija tools en Tool calling.