主题
21 · 使用状态(Using States)
GOAP 规划有很多好处,但让"循环"难以表达。 因此,Embabel 支持在 GOAP 计划中引入**状态(state)**的概念。
状态如何与 GOAP 协同
在每个状态内部,GOAP 规划照常工作:动作依据所需类型形成前置条件、依据产出类型形成效果, 规划器找出通往目标的最优动作序列。
当一个动作返回带 @State 注解的类时,框架会:
- 隐藏之前的状态对象 —— 黑板上任何已有的状态对象都会被隐藏;
- 绑定新的状态对象 —— 返回的状态被加入黑板;
- 从新状态重新规划 —— 规划器只考虑来自新状态的动作;
- 继续执行 —— 直到达成目标、或找不到任何计划。
上下文会跨状态迁移被保留 —— 非状态对象(例如用户消息、客户数据、对话历史)仍然可用。 只有状态对象会被隐藏,从而确保规划器只考虑"当前状态"的动作。
状态迁移会隐藏之前的状态对象,但并不清空黑板。非状态对象在新状态中依然可用。 若要清空整个黑板(例如为了循环),请在动作上使用
clearBlackboard = true。
何时使用状态
状态特别适合:
- 线性阶段:每个阶段自然地流向下一阶段;
- 分支工作流:一个决策点导向不同的处理路径;
- 循环模式:处理可能需要重复(例如"修改—评审"循环);
- 人在回路工作流:用户反馈决定下一个状态;
- 复杂工作流:把它当作离散阶段更容易推理。
状态允许"回跳到整个状态",而一个状态可以包含一个或多个动作。 这比传统 GOAP 更灵活 —— 在传统 GOAP 中,实现循环必须小心管理前置条件。
停留在当前状态
动作可以返回 this 来停留在当前状态。这对那些"响应输入但不改变状态"的动作很有用,例如聊天处理器:
kotlin
@State
record ChitchatState(String context) {
@Action(canRerun = true) // ①
ChitchatState respond(UserMessage message, Ai ai) {
var response = ai.generateText("Respond to: " + message.content());
// ... 发送回复
return this; // ②
}
}①
canRerun = true是必需的 —— 默认情况下,动作在一个进程中只运行一次 ② 返回this让同一个状态实例保持活跃
当一个动作返回 this:
- 状态保持活跃,不发生迁移;
- 黑板被保留(不被清空);
- 该动作可以在后续规划周期再次运行(前提是
canRerun = true)。
若没有
canRerun = true,即使它返回了this,该动作的hasRun标记也会阻止它再次执行。
循环状态(Looping States)
对于"动作可能返回到之前访问过的状态类型"的循环模式,请在参与循环的动作上使用 clearBlackboard = true:
kotlin
@State
record ProcessingState(String data, int iteration) implements LoopOutcome {
@Action(clearBlackboard = true) // ①
LoopOutcome process() {
if (iteration >= 3) {
return new DoneState(data); // ②
}
return new ProcessingState(data + "+", iteration + 1); // ③
}
}①
clearBlackboard = true让该动作可以回跳到同一个状态类型 ② 终止条件,退出循环 ③ 为下一轮迭代返回同类状态的新实例
若没有 clearBlackboard = true,规划器会看到该输出类型已在黑板上而跳过该动作。 清空黑板重置了上下文,从而让自然的循环成为可能。
只应在参与循环的动作上使用
clearBlackboard = true。 对线性状态迁移,默认行为(保留黑板)通常是更好的选择。
@State 注解
作为动作返回值、且应当触发状态迁移的类,必须带 @State 注解:
kotlin
@State
record ProcessingState(String data) {
@Action
NextState process() {
return new NextState(data.toUpperCase());
}
}继承(Inheritance)
@State 注解会沿类层次结构继承。 如果某个父类或接口带 @State,则其所有子类与实现类都自动被视为状态类型。 这意味着你不必给层次结构里的每个类都加注解 —— 只注解基类型即可。
kotlin
@State
interface Stage {} // ①
record AssessStory(String content) implements Stage { ... } // ②
record ReviseStory(String content) implements Stage { ... }
record Done(String content) implements Stage { ... }① 只有父接口需要
@State② 实现它的 record / data class 自动被当作状态类型
这适用于:
- 接口:实现
@State接口的类是状态类型; - 抽象类:继承
@State抽象类的类是状态类型; - 具体类:继承
@State类的类是状态类型; - 深层继承:该注解会沿多个层级继承。
行为(Behavior)
当一个动作返回带 @State 注解的类(或继承了 @State 的类)时:
- 之前的状态对象会从黑板上被隐藏(不是删除,而是不再可见);
- 返回的对象被绑定到黑板(以
it为名); - 规划只考虑定义在当前状态类内部的动作;
- 该状态内的任何
@AchievesGoal方法都成为潜在目标。
上下文(非状态对象)跨状态迁移被保留。 这意味着用户消息、客户数据、对话历史等在新状态中依然可用。 只有状态对象被隐藏 —— 这提供了状态作用域(state scoping),确保只考虑当前状态的动作。
对于"返回到之前访问过的状态类型"的循环状态,请在参与循环的动作上使用 @Action(clearBlackboard = true) —— 它会清空黑板(包括 hasRun 条件)并使循环得以继续。详见循环状态。
父状态接口模式(Parent State Interface Pattern)
要在多个状态之间动态选择,请定义一个由各子状态实现的父接口(或 sealed interface/class)。 得益于继承,你只需注解父接口 —— 所有实现类都自动成为状态类型:
kotlin
@State
interface Stage {} // ①
record AssessStory(String content) implements Stage { // ②
@Action
Stage assess() {
if (isAcceptable()) {
return new Done(content);
} else {
return new ReviseStory(content);
}
}
}
record ReviseStory(String content) implements Stage {
@Action
AssessStory revise() {
return new AssessStory(improvedContent());
}
}
record Done(String content) implements Stage {
@AchievesGoal(description = "Processing complete")
@Action
Output complete() {
return new Output(content);
}
}① 在父接口上加
@State② 实现它的 record/data class 不需要再加@State—— 它们从Stage继承
这个模式带来了:
- 多态返回类型:动作可以返回父接口的任意实现;
- 动态路由:由运行时值决定进入哪个状态;
- 循环:状态可以返回其它状态,最终又循环回来。
框架会自动发现父接口的所有实现,并把它们的动作注册为可能的下一步。
示例:WriteAndReviewAgent
下面的示例展示了一个完整的"写作 + 评审"工作流,包含:
- 带循环的基于状态的流程控制;
- 用
WaitFor实现的人在回路反馈; - LLM 驱动的内容生成与评估;
- 并通过状态传递可配置属性。
kotlin
abstract class Personas { // ①
static final RoleGoalBackstory WRITER = RoleGoalBackstory
.withRole("Creative Storyteller")
.andGoal("Write engaging and imaginative stories")
.andBackstory("Has a PhD in French literature; used to work in a circus");
static final Persona REVIEWER = new Persona(
"Media Book Review",
"New York Times Book Reviewer",
"Professional and insightful",
"Help guide readers toward good stories"
);
}
@Agent(description = "Generate a story based on user input and review it")
public class WriteAndReviewAgent {
public record Story(String text) {}
public record ReviewedStory(
Story story,
String review,
Persona reviewer
) implements HasContent, Timestamped {
// ... 内容格式化方法
}
@State
interface Stage {} // ②
record Properties( // ③
int storyWordCount,
int reviewWordCount
) {}
private final Properties properties;
WriteAndReviewAgent(
@Value("${storyWordCount:100}") int storyWordCount,
@Value("${reviewWordCount:100}") int reviewWordCount
) {
this.properties = new Properties(storyWordCount, reviewWordCount);
}
@Action
AssessStory craftStory(UserInput userInput, Ai ai) { // ④
var draft = ai
.withLlm(LlmOptions.withAutoLlm().withTemperature(.7))
.withPromptContributor(Personas.WRITER)
.createObject(String.format("""
Craft a short story in %d words or less.
The story should be engaging and imaginative.
Use the user's input as inspiration if possible.
# User input
%s
""",
properties.storyWordCount,
userInput.getContent()
).trim(), Story.class);
return new AssessStory(userInput, draft, properties); // ⑤
}
record HumanFeedback(String comments) {} // ⑥
private record AssessmentOfHumanFeedback(boolean acceptable) {}
@State
record AssessStory(UserInput userInput, Story story, Properties properties) implements Stage {
@Action
HumanFeedback getFeedback() { // ⑦
return WaitFor.formSubmission("""
Please provide feedback on the story
%s
""".formatted(story.text),
HumanFeedback.class);
}
@Action(clearBlackboard = true) // ⑧
Stage assess(HumanFeedback feedback, Ai ai) {
var assessment = ai.withDefaultLlm().createObject("""
Based on the following human feedback, determine if the story is acceptable.
Return true if the story is acceptable, false otherwise.
# Story
%s
# Human feedback
%s
""".formatted(story.text(), feedback.comments),
AssessmentOfHumanFeedback.class);
if (assessment.acceptable) {
return new Done(userInput, story, properties); // ⑨
} else {
return new ReviseStory(userInput, story, feedback, properties); // ⑩
}
}
}
@State
record ReviseStory(UserInput userInput, Story story, HumanFeedback humanFeedback,
Properties properties) implements Stage {
@Action(clearBlackboard = true) // ⑪
AssessStory reviseStory(Ai ai) {
var draft = ai
.withLlm(LlmOptions.withAutoLlm().withTemperature(.7))
.withPromptContributor(Personas.WRITER)
.createObject(String.format("""
Revise a short story in %d words or less.
Use the user's input as inspiration if possible.
# User input
%s
# Previous story
%s
# Revision instructions
%s
""",
properties.storyWordCount,
userInput.getContent(),
story.text(),
humanFeedback.comments
).trim(), Story.class);
return new AssessStory(userInput, draft, properties); // ⑫
}
}
@State
record Done(UserInput userInput, Story story, Properties properties) implements Stage {
@AchievesGoal( // ⑬
description = "The story has been crafted and reviewed by a book reviewer",
export = @Export(remote = true, name = "writeAndReviewStory"))
@Action
ReviewedStory reviewStory(Ai ai) {
var review = ai
.withAutoLlm()
.withPromptContributor(Personas.REVIEWER)
.generateText(String.format("""
You will be given a short story to review.
Review it in %d words or less.
Consider whether the story is engaging, imaginative, and well-written.
# Story
%s
# User input that inspired the story
%s
""",
properties.reviewWordCount,
story.text(),
userInput.getContent()
).trim());
return new ReviewedStory(story, review, Personas.REVIEWER);
}
}
}注记:
- ① Personas:可复用的 prompt contributor,给 LLM 提供关于自身角色的上下文;
- ② 父状态接口:让动作可以动态返回任意实现状态;
- ③ Properties record:把配置打包在一起,便于跨状态传递;
- ④ 入口动作:用 LLM 生成故事初稿;
- ⑤ 状态迁移:返回携带全部必要数据的
AssessStory; - ⑥ HITL 数据类型:用于承载人类反馈的简单 record/data class;
- ⑦ WaitFor 集成:暂停执行,等待用户提交反馈表单;
- ⑧ 循环动作:
clearBlackboard = true使"返回到之前访问过的状态类型"成为可能; - ⑨ 终止分支:若可接受,迁移到
Done状态; - ⑩ 循环分支:若不可接受,带着反馈迁移到
ReviseStory; - ⑪ 循环动作:
clearBlackboard = true使"回跳到AssessStory"成为可能; - ⑫ 回跳:返回新的
AssessStory,进入下一轮反馈; - ⑬ 目标达成:产出评审后故事并导出它的最终动作。
执行流程(Execution Flow)
这个 Agent 的执行流程是:
craftStory用 LLM 执行,返回AssessStory→ 进入AssessStory状态;getFeedback调用WaitFor.formSubmission()→ Agent 暂停,等待用户输入;- 用户提交反馈 →
HumanFeedback被加入黑板; assess用 LLM 解释反馈:- 若可接受:返回
Done→ 黑板被清空,进入Done状态; - 若不可接受:返回
ReviseStory→ 黑板被清空,进入ReviseStory状态;
- 若可接受:返回
- 若处于
ReviseStory:reviseStory用 LLM 执行,返回AssessStory→ 黑板被清空,回到第 2 步; - 当处于
Done时:reviewStory用 LLM 执行,返回ReviewedStory→ 目标达成。
规划器会自动处理所有迁移,包括循环。 参与循环的动作(assess 与 reviseStory)使用 clearBlackboard = true 来启用"返回到之前访问过的状态类型"。
用 WaitFor 实现人在回路(Human-in-the-Loop)
WaitFor.formSubmission() 方法是人在回路工作流的关键:
kotlin
@Action
HumanFeedback getFeedback() {
return WaitFor.formSubmission("""
Please provide feedback on the story
%s
""".formatted(story.text),
HumanFeedback.class);
}这个动作执行时:
- Agent 进程进入
WAITING状态; - 依据
HumanFeedbackrecord 的结构生成一个表单; - 用户看到提示并填写表单;
- 提交后,
HumanFeedback实例被创建并加入黑板; - Agent 带着可用的反馈恢复执行。
它与状态模式自然地结合:反馈会留在当前状态内,直到下一次状态迁移。
跨状态传递数据(Passing Data Through States)
当为循环状态使用 clearBlackboard = true 时,由于黑板会被清空,所有必要的上下文都必须通过状态 record 传递:
kotlin
@State
record AssessStory(
UserInput userInput, // 原始用户请求
Story story, // 当前故事草稿
Properties properties // 配置
) implements Stage { ... }
@State
record ReviseStory(
UserInput userInput,
Story story,
HumanFeedback humanFeedback, // 修改所需的额外上下文
Properties properties
) implements Stage { ... }建议用一个 Properties record/data class 把需要在多个状态间传递的配置值打包起来, 而不是重复重复单个字段。
对于非循环的状态迁移(不使用 clearBlackboard 时),黑板会被保留,数据可以直接从黑板访问。 当状态需要访问共享上下文(例如用户身份、对话历史)时,这很有用。
状态类的约束(State Class Requirements)
状态类必须是静态嵌套类(Java)或顶层类(Kotlin)。
不允许使用非静态内部类,因为它们持有对外层实例的引用,会导致序列化与持久化问题。 如果框架检测到带 @State 的非静态内部类,会抛出 IllegalStateException。
kotlin
// 好的:静态嵌套类(Java 的 record 隐式静态)
@State
record AssessStory(UserInput userInput, Story story) implements Stage { ... }
// 好的:顶层类
@State
record ProcessingState(String data) { ... }
// 不好的:非静态内部类 —— 会抛 IllegalStateException
@State
class AssessStory implements Stage { ... } // 非静态上下文中的内部类在 Java 中,类内部声明的 record 是隐式静态的,因此很适合作为状态类。 在 Kotlin 中,类内部声明的 data class 默认是 inner,请改用顶层声明。
顶层状态类是 Kotlin 下推荐的模式。 它们可以通过 @Provided 注解访问外层组件 —— 完整文档见《@Provided 注解》。
要点(Key Points)
- 用
@State注解状态类(或从带@State的类型继承); @State沿类层次继承 —— 只注解基类型即可;- 使用静态嵌套类(Java 的 record)或顶层类以避免持久化问题;
- 用父接口实现多态的状态返回;
- 状态内的动作会被自动发现并注册;
- 状态作用域:进入新状态时,之前的状态被隐藏 —— 只有当前状态的动作可用;
- 上下文会被保留:非状态对象(用户数据、对话等)跨迁移依然可用;
- 黑板会被保留:状态迁移隐藏之前的状态,但保留黑板上其它所有内容;
- 停留在状态内:返回
this并配合canRerun = true,即可不迁移地停留在当前状态; - 对循环状态,用
@Action(clearBlackboard = true)以启用"返回到之前访问过的状态类型"; - 使用
clearBlackboard = true时,把所有必要数据通过状态 record 字段传递; - 目标由终结状态动作上的
@AchievesGoal定义; - 状态内的人在回路交互使用
WaitFor; - 在状态内部,正常的 GOAP 规划仍负责编排动作顺序。