Workflow entwerfen¶
Das Framework kümmert sich nur um Mechanik: wie ein Schritt läuft, wie Sessions aneinander anschließen, was bei Fehlern passiert, wie Kontext gespart wird. Den Workflow schreibst du — das Framework weiß nicht, an welchem Projekt du arbeitest oder welche Sprache du benutzt, und soll es auch nicht wissen. Diese Seite erklärt, wie man einen Workflow entwirft; die vollständigen Feldtabellen von Step und Workflow stehen in der Python-API.
Welches Problem das löst¶
Ein long-horizon Run ist nichts, was sich in einem einzigen Prompt sagen ließe: erst die Anforderung klären, dann recherchieren, dann implementieren, dann prüfen — jeder Abschnitt hat seine eigene Rolle, seinen eigenen Kontext, seine eigenen Abnahmekriterien. Schreibt man alles in einen Prompt, entscheidet das Modell selbst, welchen Abschnitt es überspringt; schreibt man einen Workflow, werden Reihenfolge, Abbruchbedingungen und Zustandsübergabe zu Python-Code — lesbar, testbar, und man kann nur den kaputten Schritt neu laufen lassen.
Workflow macht genau drei Dinge:
- eine Reihe von Schritten der Reihe nach ausführen
- entscheiden, was jeder Schritt vom Vorangegangenen sieht (drei Arten der Session-Verkettung + ein
ctx-Dictionary) - entscheiden, wann wiederholt und wann vorzeitig abgebrochen wird
Es enthält keinerlei Domänenannahmen. Wo geschnitten wird, was jeder Schritt abnimmt, was bei Nichtbestehen passiert — genau diese vier Dinge sind „Workflow entwerfen".
Wie man es benutzt (Minimalcode)¶
# flows.py
from flower import AgentSpec, Step, Workflow
terse = AgentSpec(
name="terse",
instructions="回答极简,一行以内,不解释不寒暄。",
allowed_tools=["Read", "Glob"],
max_turns=4,
)
def main() -> Workflow:
return Workflow([
# Neue Session: sieht nur, was im Prompt übergeben wurde
Step("取词", terse, "读 seed.txt,只回文件里那个词。"),
# Wieder eine neue Session, das Ergebnis des vorigen Schritts wird in den Prompt injiziert (billig, verhindert Verschmutzung)
Step("造句", terse, lambda ctx: f"用「{ctx['取词']}」造一个五字短句,只回短句。"),
])
Das Argument von flower run ist Modul:Attribut oder Dateipfad:Attribut. Ist das aufgelöste Objekt aufrufbar, wird es einmal aufgerufen; das resultierende Workflow wird dann ausgeführt. Am Ende gibt das Terminal die Gesamtkosten und den Pfad des Run-Manifests aus.
Ein eigenes Treiberprogramm geht auch, das erste Argument von Workflow.run ist ein Runtime:
Was tatsächlich passiert¶
Was ein Step bekommt und was er zurückgeben muss¶
Step ist keine Funktion, sondern eine Deklaration. Ausgeführt wird tatsächlich Runtime.run(step.spec, gerenderter Prompt, ...) — ein Schritt = ein Runtime.run = eine Session.
Die ersten drei Felder sind Positionsargumente, Step(name, spec, prompt):
name— der Schrittname. Er ist gleichzeitig der Schlüssel inctx, der Zeilenname inruns/manifest.jsonund der Schlüssel der prozessübergreifenden Lineage.spec— mit welchemAgentSpecgelaufen wird. Bestimmt Tool-Whitelist, Modell und Budget dieses Schritts.prompt— einstroder(ctx) -> str. Ist es aufrufbar, bekommt es das aktuellectx; das ist der billigste Weg, das Ergebnis des vorherigen Schritts einzuspeisen (der andere ist Session-Verkettung, siehe unten).
„Zurückgegeben" wird ein StepResult, aber im Workflow bekommst du zwei Dinge:
ctx[step.name]— standardmäßigresult.text, mitreducestattdessen dessen Rückgabewert;ctx["_results"][step.name]— das vollständigeStepResult(Kosten, Turns, Anzahl Versuche,session_id).
result.text nimmt nur den Fließtext des Main Threads auf: Äußerungen eines Subagents liegen in dessen eigenem Transcript, das an ihn übergebene Task Brief ist kind="prompt", der synthetische Fehler bei Verbindungsabbruch ist kind="error" — keines der drei geht ein.
reduce: kein Syntaxzucker¶
Standardmäßig wird der Wortlaut des Modells weitergereicht. Bei manchen Schritten darf der Wortlaut nicht unverändert weitergereicht werden:
In der Praxis klebt der Schritt zur Anforderungsklärung den kompletten Code zusätzlich zu den vier Abschnitten mit hinein. Weitergereicht werden dürfen nur die geparsten vier Abschnitte, sonst landet dieser ganze Code im Prompt des nächsten Schritts. clarify_step fängt genau das über dieses Feld ab.
reduce muss eine synchrone Funktion sein; gate / when / on_reject dürfen async sein.
Wie Zustand durch ctx fließt¶
ctx ist ein dict[str, Any], nämlich Workflow.context selbst. Nach jedem Schritt wird nach dieser Tabelle geschrieben:
| Fall | ctx[Schrittname] | Sonstiges |
|---|---|---|
when(ctx) liefert False | wird nicht geschrieben, ganzer Schritt übersprungen | erzeugt kein Result, kommt auch nicht in _results |
| bestanden | reduce(result, ctx), ohne Angabe result.text | |
fehlgeschlagen + on_fail="stop" (Default) | wird nicht geschrieben | schreibt ctx["_failed_at"], der gesamte Workflow stoppt bei diesem Schritt |
fehlgeschlagen + on_fail="skip" | wird nicht geschrieben | läuft weiter |
fehlgeschlagen + on_fail="continue" | result.text (unvollständig, ohne reduce) | läuft weiter |
Ob bestanden oder nicht, ctx["_results"][Schrittname] wird immer geschrieben; ist result.session_id nicht leer, wird sie zusätzlich in ctx["_sessions"] geschrieben und in der Lineage vermerkt.
Ob dieser Workflow erfolgreich war, entscheidet ctx.get("_failed_at") — nicht, ob der letzte Schritt eine Ausgabe hatte.
Alle Schlüssel mit führendem Unterstrich setzt Workflow.run selbst: _runtime, _on_event, _sessions, _results, _lineage, _woke, _aborted, _failed_at — benutze sie nicht als eigene Schrittnamen. Die einzelnen Mechanismen legen zusätzlich eigene ab (_brief / _goal / _verdict usw.); die vollständige Liste steht in der Python-API.
Davon sind _runtime und _on_event für gate gedacht: ein Gate kann selbst einen Agent zur Beurteilung losschicken, und dieser Beurteilungsvorgang wird trotzdem an die UI gemeldet — sonst wäre die Oberfläche für die zehn, fünfzehn Sekunden schwarz und sähe aus wie eingefroren. Der Goal Guard ist genau so implementiert.
ctx ist dasselbe Dict: läuft dasselbe Workflow-Objekt ein zweites Mal, sind die Schlüssel des ersten Laufs noch da. Für einen sauberen Neustart ein neues Objekt anlegen oder explizit context={} übergeben.
Bei on_fail=skip wird ctx[Schrittname] nicht geschrieben
Ein nachgelagertes lambda ctx: ctx["某步"] läuft direkt in einen KeyError. Willst du mit dem unvollständigen Ergebnis weiterlaufen, nimm on_fail="continue"; willst du wirklich überspringen, muss der nachgelagerte Schritt selbst per ctx.get(...) absichern.
Beurteilen und zurückweisen: gate, on_reject, StepAbort¶
gate(result, ctx) -> bool beurteilt „ist durchgelaufen, aber taugt es?". Zwei Details, die man kennen muss:
- Ist
result.okfalsch, wirdgategar nicht erst aufgerufen (Kurzschluss). - Pro Versuch wird es genau einmal aufgerufen, das Ergebnis wird für später aufgehoben — es kann Seiteneffekte haben. Das Gate von
clarify_stepschreibt das Brief auf Platte; mehrfaches Auslösen heißt mehrfaches Schreiben.
Wie nach einem nicht bestandenen Gate wiederholt wird, hängt davon ab, ob on_reject gesetzt ist:
| Wie die nächste Runde läuft | Name im Manifest | |
|---|---|---|
nur retries | von vorn, originaler Prompt, originales resume_from | X#retry1 |
plus on_reject | die gerade abgelehnte Session wird fortgesetzt, der Prompt ist der Rückgabewert von on_reject, fork wird auf False gezwungen | X#round2 |
Der zweite Fall ist „zurückgeben, sagen, was fehlt, und nachbessern lassen" — die bereits geleistete Arbeit und der Kontext sind noch da. Gibt on_reject einen leeren String zurück, oder hat dieser Versuch gar keine session_id erhalten, degradiert es zum Lauf von vorn.
gate kann außerdem StepAbort werfen, was bedeutet: weitere Versuche bringen nichts, verbrauche die restlichen Runden nicht:
from flower import StepAbort
def gate(result, ctx):
if "这个环境装不了依赖" in result.text:
raise StepAbort("环境缺依赖,再跑几轮也一样")
return "验收通过" in result.text
Nach dem Wurf: der Grund landet in ctx["_aborted"], der Schritt gilt als fehlgeschlagen und folgt on_fail (Default "stop"), die Retry-Schleife bricht sofort ab, und von den verbleibenden retries wird keiner verbraucht.
Merke den Unterschied: False zurückgeben heißt „diesmal nicht, noch eine Runde"; StepAbort heißt „noch eine Runde bringt nichts". Der typische Fall ist ein Ziel, das in dieser Umgebung als nicht machbar beurteilt wurde und bei dem niemand zum Fragen da ist — weiterzudrehen ist dann die teuerste Option.
Die zwei Retry-Ebenen nicht verwechseln¶
Step.retries | Runtime(resilience=...) | |
|---|---|---|
| Wofür | fachliches Scheitern: gate nicht bestanden, result.ok falsch | Infrastruktur: Netzwerkzucken, Verbindungsabbruch, 5xx |
| Wie wiederholt wird | ganzer Schritt von vorn, gleicher Prompt und gleiches resume_from | Resume ab der Abbruchstelle, die bisherigen Kosten sind nicht umsonst |
| Was vorher passiert | nichts | DNS- + TCP-Probe warten, bis das Netz zurück ist (kein HTTP, keine Credentials, die Probe muss kostenlos sein) |
| Nicht wiederholbar | — | falsche Credentials, falsche Parameter stoppen sofort, kein endloses Warten |
Der Prompt für das Fortsetzen enthält absichtlich keinerlei Fehlerdetails — das Modell muss wissen „du wurdest unterbrochen, mach weiter", nicht, ob es ENOTFOUND oder 503 war.
Schritte verketten¶
Für die Zustandsübergabe zwischen Schritten gibt es drei Verkettungsarten; die Wahl bestimmt, was der nächste Schritt sieht:
| Schreibweise | Was der nächste Schritt sieht | Wofür |
|---|---|---|
resume_from=None (Default) + Injektion im Prompt | nur die von dir injizierten Worte | unabhängige Schritte. Billig, verhindert Verschmutzung |
resume_from="Name des vorherigen Schritts" | die vollständige Session-Historie | wenn zusammenhängendes Gedächtnis nötig ist |
resume_from="Name des vorherigen Schritts" + fork=True | vollständige Historie, aber in einem neuen Zweig | Review / parallele Varianten / Retries, die den Originalstrang nicht verschmutzen |
Der von resume_from referenzierte Schritt muss tatsächlich eine Session erzeugt haben. Wurde er per when übersprungen oder lief gar nicht, wirft Workflow.run direkt einen ValueError — es degradiert nicht stillschweigend zu einer neuen Session, weil damit die Annahme „zusammenhängendes Gedächtnis" heimlich verfiele.
Ein paar Entwurfserfahrungen, für die wiederholt bezahlt wurde:
- Ein Schritt, ein abnehmbares Ziel. Schrittgrenzen sind Kontextgrenzen: dort, wo
resume_from=Nonesteht, sind die vorherigen Tool-Ergebnisse endgültig nicht mehr dauerhaft präsent. Siehe Kontextökonomie. - Im Zweifel erst
clarify_step. In long-horizon Runs ist „das Ziel falsch verstanden" der teuerste Fehler, und ausgerechnet er gehört zu der Sorte, die die kontextsparenden Schichten nicht wegräumen können. Siehe Clarify. - Delegierte Aufgaben müssen selbsttragend sein. Ein Subagent hat einen sauberen Kontext, er weiß nicht, was der Koordinator weiß. Nötiger Hintergrund gehört ins Task Brief, oder sag ihm, welches Artefakt er lesen soll.
- Lange Ausgaben gehen auf die Platte, nicht in die Antwort. Das steht bereits in
WORKER_RULES; deineinstructionssollen es nicht wieder aufheben („zeig mir das komplette Log hier in der Antwort"). gateprüft vorrangig harte Bedingungen. Ob eine Datei existiert, ob der Exit-Code 0 ist — was sich in einer Zeile Python entscheiden lässt, dafür schickt man kein Modell los. Soll ein Modell urteilen, nimm das fertigewith_goal— es ersetzt das Gate durch eine Implementierung, die einen eigenständigen Judge laufen lässt; bastle das nicht selbst im Gate nach.- Parallele Änderungen am selben Repo bekommen
worker(isolate=True). Der Abschluss (Merge, Worktrees aufräumen, PR öffnen) bleibt derzeit deinem Workflow überlassen; die Harness garantiert nur, dass die Änderungen im jeweils eigenen Worktree landen.
Die Workbench muss am Workflow hängen¶
Überall, wo der Workflow Dateien in die Workbench schreibt — typischerweise clarify_step(brief_path=...) — musst du selbst eine Workbench anlegen und sie gleichzeitig an Workflow.workbench hängen und dem Runtime übergeben:
from pathlib import Path
from flower import (HumanChannel, Runtime, Step, Workbench, Workflow,
clarify_step, coordinator, worker)
wb = Workbench(Path.cwd()).ensure()
ch = HumanChannel(log_path=wb.notes / "问答记录.md", timeout_s=1800.0)
主控 = coordinator("协调者", "", {
"coder": worker("写代码与测试。要动手实现的活派给它。",
"你负责实现。每改一处就跑一次验证,别攒到最后。"),
}, channel=ch)
wf = Workflow(
[
clarify_step(ch, brief_path=wb.notes / "需求.md", prompt="帮我做一个 X"),
Step("干活", spec=主控, prompt=lambda ctx: f"照这份需求做:\n\n{ctx['确认需求']}"),
],
channel=ch,
workbench=wb,
)
rt = Runtime(workspace=Path.cwd(), run_dir="runs", workbench=wb)
Dass channel am Workflow hängt, hat zwei Gründe: run() verbindet dessen on_event mit demselben Event-Ausgang (nur solange channel.on_event noch None ist), und das Treiberprogramm erfährt über dieses Feld, wem es antworten soll.
Selbst zusammengebaute Workbench-Pfade fallen stillschweigend aus
Die Standardposition bei Runtime(workbench=True) ist <run_dir>/workbench, die von Workbench(ws) dagegen <ws>/.flower — das sind nicht dieselben Verzeichnisse. Wird der Workflow von der CLI aufgerufen, sieht er run_dir nicht; ein selbst zusammengebauter Pfad zeigt dann einfach woandershin, das Brief wird nach Verzeichnis A geschrieben, der injizierte Index scannt Verzeichnis B — und es gibt keinen Fehler. Legt man ein Objekt an und benutzt es auf beiden Seiten, existiert das Problem nicht; ist Workflow.workbench gesetzt, wird -W auf der Kommandozeile ignoriert und dieses Feld gilt.
continuous=True: derselbe Pfad ein zweites Mal¶
Die drei Verkettungsarten oben betreffen Schritte innerhalb eines Runs. Prozessübergreifend ist eine andere Achse:
Läuft derselbe Workspace ein zweites Mal, setzt jeder Schritt die Session vom letzten Mal fort — über die Zuordnung „Schrittname → session_id" in <run_dir>/lineage.json. Beim Laden geht jeder Eintrag durch runtime.has_session(), um zu prüfen, ob die Session noch in der Datenbank ist; nur lebende werden benutzt: die Lineage-Datei kann sessions.db überleben, und ein Resume auf eine nicht existierende Session fliegt einem erst um die Ohren, wenn der Subprozess gestartet ist.
Drei Konsequenzen:
resume_from=Noneheißt nicht „ganz neue Session". Beim ersten Lauf schon, beim zweiten nicht. Soll es jedes Mal eine neue Session sein, schreib explizitWorkflow(..., continuous=False).- Bei Continuity wächst der Kontext stetig. Willst du beim Fortsetzen etwas anderes sagen, nimm
Step.resume_prompt— was im Kontext der Gegenseite schon steht, sollte man nicht erneut schicken. - Schritte mit explizitem
resume_fromsind nicht betroffen, das hat Vorrang.
Der Schrittname ist der prozessübergreifende Schlüssel
Einen Schrittnamen zu ändern heißt, die Lineage dieses Schritts zu kappen: beim nächsten Lauf wird nicht mehr fortgesetzt, und es gibt keinen Fehler. Retry-Namen mit den Suffixen #retry1 / #round2 gehen nicht in die Lineage (vermerkt wird immer der Originalname); das ist auch einer der Gründe, warum „der Judge ist immer eine neue Session" funktioniert.
Vollständiger Entwurf und --new siehe Continuity.
Wann man es nicht benutzen sollte¶
- Nur ein Agent, und keine Beurteilung nötig — kein
Workflowdrumherum. Direktawait rt.run(spec, "…"), oder auf der Kommandozeileflower once "读一眼这个仓库". -
Die Form ist genau „Anforderung klären → Ziel setzen → arbeiten" — nimm das fertige
starter_flow(), statt es selbst zu schreiben:from flower import starter_flow wf = starter_flow("帮我做一个 X", workspace=".", run_dir="runs", rounds=3, timeout_s=1800.0, isolate=False)Es sind drei Schritte:
确认需求→设定目标→干活(mit Beurteilungsschleife, der Beurteilungsschritt heißt干活·判定#N). Mitgoal=Falseentfallen zweiter Schritt und Beurteilungsschleife, mitclarify_only=Truebleibt nur der erste Schritt. Es bringt eigenenHumanChannelund eigeneWorkbenchmit und hängt sie an den Workflow, also nimmRuntime(workbench=wf.workbench)direkt so und bau nicht noch eine zweite zusammen.Ohne Code geht es auch: im Projektverzeichnis
flower "帮我做一个 X"startet genau das. Es ist keine „empfohlene Workflow-Gestaltung", sondern nur der Weg, ohne Konfiguration loszulaufen. -
Schritte feiner geschnitten als „ein abnehmbares Ziel" — reiner Verlust. Jeder Schritt startet eine neue Session, und eine neue Session hat eine Startuntergrenze (beim Koordinator gemessen rund 34k Kontext), die sich nicht wegamortisieren lässt.
- Nachträglich auf eine bestimmte Nachricht zurückrollen — über
Workflowgeht das nicht, es übergibt nieresume_at. Ruf direktRuntime.run(spec, "从这里重来", resume=sid, resume_at=uuid)auf.
Wie man die Rolle wählt (coordinator / worker / clarify / judge / oracle) und die feldweise Semantik von Step und Workflow stehen in der Python-API; die Begriffe im Glossar.