主题
10 · 注解(Annotations)
注解模型(Annotation model)
Embabel 提供了一套 Spring 风格的注解模型,用来定义 Agent、动作、目标与条件。 这是在 Java 中推荐使用的模型,在 Kotlin 中同样有吸引力。
@Agent 注解
该注解用在类上,用来定义一个 Agent。 它是一个 Spring 构造型(stereotype)注解,因此会触发 Spring 组件扫描 —— 你的 Agent 类会被自动注册为 Spring bean, 同时也会注册到 Agent 框架中,从而可以在 Agent 进程里使用。
你必须提供 description 参数,它是 Agent 的人类可读描述。 这一点尤其重要,因为它可能被 LLM 用于 Agent 选择。
@EmbabelComponent 注解
该注解用在类上,表示这个类暴露了可供 Agent 使用的动作、目标与条件,但它本身不是一个 Agent。 它是 Spring 构造型注解,会触发组件扫描;你的 Embabel 组件会被自动注册为 Spring bean, 并注册到 Agent 框架中,使它提供的动作、目标与条件可以在 Agent 进程里使用。
Embabel 组件与 Utility AI 规划器配合使用时最有价值 —— 后者会从所有可用动作中选出下一个最有价值的动作。
@Action 注解
@Action 注解用来标记在 Agent 中执行动作的方法。
可以在注解上指定动作的元数据:
description:动作的人类可读描述。pre:除输入类型之外,动作执行前必须满足的额外前置条件列表。post:除输出类型之外,动作执行后可能满足的额外后置条件列表。canRerun:布尔值,表示动作在已经执行过之后是否可以重跑。默认false。readOnly:布尔值,表示该动作没有外部副作用。只读动作仅分析数据、产出派生对象, 不修改外部系统(API、数据库、文件等)。这在学习/追赶(learning/catchup)模式下有用 —— 你希望摄入并理解数据,但不触发任何变更。默认false。clearBlackboard:布尔值,表示该动作完成后是否清空黑板。 为true时,黑板上所有对象都会被移除(该动作的输出除外)。这适合在多步工作流中重置上下文, 也可以通过丢弃不再需要的对象来让流程的持久化更高效。默认false。cost:动作的相对成本,0 ~ 1。默认0.0。value:执行该动作的相对价值,0 ~ 1。默认0.0。
清空黑板(Clearing the Blackboard)
clearBlackboard 属性在两种场景下有用:
- 多步工作流:你想重置处理上下文;
- 循环状态:某个动作返回到之前访问过的状态类型。
当一个 clearBlackboard = true 的动作完成时,黑板上除该动作输出以外的所有对象都会被移除。 这可以防止累积的中间数据影响后续处理,并使循环成为可能。
循环状态(Looping States)
clearBlackboard 最常见的用途是在基于状态的工作流中实现循环:
kotlin
@State
record ProcessingState(String data, int iteration) {
@Action(clearBlackboard = true) // ①
LoopOutcome process() {
if (iteration >= 3) {
return new DoneState(data);
}
return new ProcessingState(data + "+", iteration + 1); // ②
}
}①
clearBlackboard = true让"返回同一个状态类型"成为可能 ② 如果不清理,返回ProcessingState会被阻止,因为该类型已经存在于黑板上
关于循环状态模式的更多细节,见「使用状态(Using States)」。
重置上下文(Resetting Context)
也可以用 clearBlackboard 来重置多步工作流中的上下文:
java
@Agent(description = "Multi-step document processing")
public class DocumentProcessor {
@Action(clearBlackboard = true) // ①
public ProcessedDocument preprocess(RawDocument doc) {
return new ProcessedDocument(doc.getContent().trim());
}
@AchievesGoal(description = "Produce final output")
@Action
public FinalOutput transform(ProcessedDocument doc) { // ②
return new FinalOutput(doc.getContent().toUpperCase());
}
}①
preprocess完成后黑板被清空,只剩下ProcessedDocument,原始的RawDocument被移除。 ②transform动作只会收到ProcessedDocument,不会收到任何更早的输入。
⚠️ 不要在达成目标的动作(带
@AchievesGoal)上使用clearBlackboard。 清空黑板会移除hasRun追踪条件,可能干扰目标达成。请把clearBlackboard用在中间动作上。
用 @Cost 动态计算成本(Dynamic Cost Computation with @Cost)
@Action 上的 cost 与 value 字段只能指定静态值;你也可以用 @Cost 注解在规划期动态计算这些值。 当动作的成本取决于黑板当前状态时,这就很有用。
@Cost 注解标记一个返回成本值(0.0 ~ 1.0 之间的 double)的方法。 然后你在 @Action 注解里通过 costMethod 或 valueMethod 引用该方法。
java
@Agent(description = "Processor with dynamic cost")
public class DataProcessor {
@Cost(name = "processingCost") // ①
public double computeProcessingCost(@Nullable LargeDataSet data) { // ②
if (data != null && data.size() > 1000) {
return 0.9; // 大数据集成本高
}
return 0.1; // 小数据集或数据集缺失时成本低
}
@Action(costMethod = "processingCost") // ③
public ProcessedData process(RawData input) {
return new ProcessedData(input.transform());
}
}注记:
- ①
@Cost注解标记一个方法用于动态成本计算,name参数用来标识这个成本方法; - ②
@Cost方法中的领域对象参数必须可空。如果对象不在黑板上,会传入null; - ③
costMethod字段按名称引用该@Cost方法。
与 @Condition 方法的关键差异:
@Cost方法中所有领域对象参数必须可空(Java 用@Nullable,Kotlin 用?);- 当某个领域对象不在黑板上时,传入
null,而不是让方法失败; - 方法必须返回 0.0 ~ 1.0 之间的 double;
- 可以把
Blackboard作为参数传入,直接访问全部可用对象。
你也可以用 valueMethod 计算动态价值:
java
@Agent(description = "Agent with dynamic value computation")
public class PrioritizedAgent {
@Cost(name = "urgencyValue")
public double computeUrgency(@Nullable Task task) {
if (task == null) {
return 0.5;
}
if (task.getPriority() == Priority.HIGH) {
return 1.0;
}
if (task.getPriority() == Priority.MEDIUM) {
return 0.6;
}
return 0.2;
}
@AchievesGoal(description = "Process high-priority tasks")
@Action(valueMethod = "urgencyValue")
public Result processTask(Task task) {
return new Result(String.format("Processed: %s", task.getName()));
}
}@Cost 方法在规划期、动作执行之前被调用。它让规划器能够基于运行时状态,对"优先选哪个动作"做出有依据的决策。
动态成本与 Utility 规划(PlannerType.UTILITY)配合尤其有用 —— 在 utility 规划中,成本/价值权衡是核心概念。 utility 规划器依据**净价值(value − cost)**评估动作,因此动态成本计算对精细化决策至关重要。
@Condition 注解
@Condition 注解用来标记评估条件的方法。 它们可以接收 OperationContext 参数,以访问黑板及其它基础设施。 如果它们接收领域对象参数,那么在有合适的实例可用之前,该条件会自动为 false。
条件方法不应有副作用(例如写黑板)。这很重要,因为它们可能被多次调用。
用 SpEL 表达动态条件(Dynamic Conditions with SpEL)
除了使用 @Condition 方法,你还可以用 Spring 表达式语言(SpEL) 直接在 @Action 注解上指定动态前置条件。 这些表达式针对黑板求值,让你不必另写条件方法就能基于运行时状态构造条件。
表达式语言是可插拔的,但目前 SpEL 是唯一受支持的实现。完整语法细节见 Spring 的 SpEL 官方文档。
SpEL 条件写在 pre 数组里,带 spel: 前缀:
java
@Action(
pre = {"spel:assessment.urgency > 0.5"} // ①
)
public void handleUrgentIssue(Issue issue, IssueAssessment assessment) {
// 只有紧急度超过 0.5 时该动作才运行
}①
spel:前缀表示这是一个针对黑板求值的 SpEL 表达式。
表达式语法
SpEL 表达式通过绑定名引用黑板对象(通常是类名的 camelCase 形式)。表达式必须求值为布尔值。
java
@Agent(description = "Issue triage agent")
public class IssueTriageAgent {
@Action(
pre = {"spel:issueAssessment.urgency > 0.0"} // ①
)
public void escalateUrgentIssue(
GHIssue issue,
IssueAssessment issueAssessment
) {
logger.info("Escalating urgent issue #{}", issue.getNumber());
}
@Action(
pre = {"spel:ghIssue instanceof T(org.kohsuke.github.GHPullRequest) && ghIssue.changedFiles > 10"} // ②
)
public void reviewLargePullRequest(
GHPullRequest issue,
PullRequestAssessment assessment
) {
logger.info("Large PR detected: #{} with {} files changed",
issue.getNumber(), issue.getChangedFiles());
}
}① 简单的属性比较:仅当
urgency属性大于 0.0 时动作才触发。 ② 类型检查 + 属性访问:仅当 pull request 的变更文件数超过 10 时动作才触发;T()运算符用于instanceof检查中的 Java 类型引用。
集合过滤(Collection Filtering)
SpEL 的集合选择语法(?[])在检查黑板上集合的条件时很有用:
java
@Action(
pre = {
"spel:newEntity.newEntities.?[#this instanceof T(com.example.domain.Issue) " +
"&& !(#this instanceof T(com.example.domain.PullRequest))].size() > 0" // ①
}
)
public IssueAssessment reactToNewIssue(
GHIssue ghIssue,
NewEntity<?> newEntity,
Ai ai
) {
// 仅当 newEntities 中包含 Issue 但不是 PullRequest 时触发
return ai.withDefaultLlm() // 该示例使用 claude-sonnet-4
.creating(IssueAssessment.class)
.fromTemplate("issue_triage", Map.of("issue", ghIssue));
}
@Action(
pre = {
"spel:newEntity.newEntities.?[#this instanceof T(com.example.domain.PullRequest)].size() > 0" // ②
}
)
public PullRequestAssessment reactToNewPullRequest(
GHPullRequest pr,
NewEntity<?> newEntity,
Ai ai
) {
// 仅当 newEntities 中包含 PullRequest 时触发
return ai.withDefaultLlm() // 该示例使用 claude-sonnet-4
.creating(PullRequestAssessment.class)
.fromTemplate("pr_triage", Map.of("pr", pr));
}①
?[]运算符用于过滤集合,#this指代每个元素。该表达式检查"至少有一个元素是 Issue 但不是 PullRequest"。 ② 更简单的过滤:检查是否存在PullRequest实例。
常见 SpEL 模式
| 模式 | 说明 |
|---|---|
spel:obj.property > value | 简单的属性比较 |
spel:obj instanceof T(com.example.Type) | 用全限定类名做类型检查 |
spel:collection.size() > 0 | 检查集合非空 |
spel:collection.?[condition].size() > 0 | 检查过滤后的集合有元素 |
spel:obj.property != null | 空值检查 |
spel:condition1 && condition2 | 用 AND 组合条件 |
spel:condition1 || condition2 | 用 OR 组合条件 |
简单的属性检查与类型判别,用 SpEL 条件;复杂逻辑、或需要在多个动作间复用的条件,优先用 @Condition 方法。对于"只要某个特定类型被加入黑板就触发动作"的响应式场景,考虑改用 trigger 字段 —— 它比写 SpEL 表达式更简单。
黑板绑定名默认由类名转成 camelCase 得到。 你也可以通过动作上的 outputBinding,或用特定名称把对象加入黑板,来显式指定绑定名。
Action 与 Condition 方法都可以从父类继承 —— 也就是说,父类上带注解的方法会被当作子类实例上的动作。 请给你的 Action 与 Condition 方法取唯一的名字,以便规划器能区分它们。
参数(Parameters)
@Action 方法必须至少有一个参数。 @Condition 方法可以有零个或多个参数,除此之外在参数规则上与 @Action 方法相同。 参数顺序不重要。
参数分两类:
- 领域对象(Domain objects):动作方法的常规输入。它们由黑板支撑,会作为动作方法的输入使用。 可空的领域对象参数:如果它在黑板上非空,就会被填充 —— 这实现了"锦上添花但非必需"的参数。 Kotlin 用
?声明可空参数;Java 用org.springframework.lang.Nullable或其它Nullable注解标记。 - 基础设施参数(Infrastructure parameters):例如
OperationContext、ProcessContext、Ai,可用在动作或条件方法中。
领域对象驱动规划,它们指定了动作的前置条件。
动作方法里可以使用 ActionContext 或它的子类型 ExecutingOperationContext, 它新增了 asSubProcess 方法,用来在子进程中运行其它 Agent —— 这是组合的重要一环。
参数请尽量使用最不具体的类型:除非你要创建子进程,否则用 OperationContext。
自定义参数(Custom Parameters)
除了上述两类默认参数,你还可以通过实现 ActionMethodArgumentResolver 接口来提供自定义参数。 该接口的两个主要方法是:
supportsParameter:指明支持哪类参数;resolveArgument:把参数解析成用于调用动作方法的对象。
注意它与 Spring MVC 的相似性 —— 在 Spring MVC 中你可以通过实现 HandlerMethodArgumentResolver 提供自定义参数。 所有默认参数也都是由 ActionMethodArgumentResolver 实现提供的。
要注册你的自定义参数解析器,请在 Spring 配置中把它提供给 DefaultActionMethodManager 组件。 通常你会把(部分)默认解析器连同你的自定义解析器一起注册,以支持默认参数。
请确保把 BlackboardArgumentResolver 注册为最后一个解析器,以保证其它解析器优先。
@Provided 注解
@Provided 注解把动作方法的某个参数标记为由平台上下文(例如 Spring 的 ApplicationContext)提供, 而不是从黑板解析。
它尤其适用于:
- 在
@State类内部访问外层组件(@State类必须是 static 嵌套类或顶层类); - 注入那些不是领域对象、但处理过程中需要的服务;
- 访问配置或其它由平台管理的 bean。
java
@EmbabelComponent
public class ReservationFlow {
private final BookingService bookingService;
private final NotificationService notificationService;
public ReservationFlow(BookingService bookingService, NotificationService notificationService) {
this.bookingService = bookingService;
this.notificationService = notificationService;
}
@Action
public CollectDetails start(UserRequest request) {
return new CollectDetails(request.customerId());
}
@State
public record CollectDetails(String customerId) {
@Action
public ConfirmReservation confirm(
ReservationDetails details, // ①
@Provided ReservationFlow flow // ②
) {
var booking = flow.bookingService.reserve(details);
flow.notificationService.sendConfirmation(booking);
return new ConfirmReservation(booking);
}
}
@State
public record ConfirmReservation(Booking booking) {
@AchievesGoal(description = "Reservation completed")
@Action
public BookingResult complete() {
return new BookingResult(booking);
}
}
}①
ReservationDetails是从黑板解析的领域对象。 ②ReservationFlow通过@Provided从 Spring 上下文注入 —— 由此获得外层组件中的服务。
工作原理
当 Spring 可用时,SpringContextProvider 会从 ApplicationContext 查找 bean 来解析 @Provided 参数。 参数类型必须与上下文中的某个 bean 匹配。
java
@State
public record ProcessingState(String data) {
@Action
public NextState process(
@Provided MyService myService, // ①
@Provided AppConfig config // ②
) {
var result = myService.process(data, config.getSetting());
return new NextState(result);
}
}① 任何 Spring bean 都可以用
@Provided注入。 ② 一个方法里可以有多个@Provided参数。
何时使用 @Provided
当你需要访问以下内容时使用它:
- 从
@State动作访问外层的@EmbabelComponent或@Agent类; - 属于基础设施关注点、而非领域对象的服务;
- 配置或环境值。
不要用 @Provided 处理:
- 应当驱动规划的领域对象(请改用普通参数);
- 需要在黑板上被追踪的对象。
由于 @State 类必须是静态嵌套类或顶层类,@Provided 是访问外层组件服务的推荐方式 —— 它让状态类保持可序列化,同时仍能拿到依赖。
@Provided 参数在黑板参数之前解析。 如果某个类型两个来源都能提供,@Provided 优先。
按名称绑定(Binding by name)
@RequireNameMatch 注解可用于按名称绑定参数。
用 trigger 实现响应式触发(Reactive triggers with trigger)
@Action 注解上的 trigger 字段可以启用响应式行为:只有当某个特定类型是最近加入黑板的值时,动作才会触发。 这在事件驱动场景中很有用 —— 你希望针对某个特定事件做出反应,即使黑板上同时存在多种类型的多个参数。
例如在聊天系统里,你可能希望动作仅在新用户消息到来时触发,而不是在其它上下文更新时触发:
java
@Agent(description = "Chat message handler")
public class ChatAgent {
@AchievesGoal(description = "Respond to user message")
@Action(trigger = UserMessage.class) // ①
public Response handleMessage(
UserMessage message,
Conversation conversation // ②
) {
return new Response("Received: " + message.content());
}
}①
trigger字段表示:只有UserMessage是最近加入黑板的结果时,该动作才触发。 ②Conversation也必须可用,但不要求它是触发事件。
没有 trigger 时,只要所有参数在黑板上可用,动作就会触发。 有了 trigger,指定的类型还必须同时是最近加入的值。
这在以下情况尤其有用:
- 你有多个动作分别处理不同的事件类型;
- 你想区分"数据已可用"与"事件刚刚发生";
- 你在构建事件驱动或响应式工作流。
java
@Agent(description = "Multi-event processor")
public class EventProcessor {
@Action(trigger = EventA.class) // ①
public Result handleEventA(EventA eventA, EventB eventB) {
return new Result("Triggered by A");
}
@AchievesGoal(description = "Handle event B")
@Action(trigger = EventB.class) // ②
public Result handleEventB(EventA eventA, EventB eventB) {
return new Result("Triggered by B");
}
}①
handleEventA在EventA被加入(且EventB可用)时触发。 ②handleEventB在EventB被加入(且EventA可用)时触发。
trigger 字段检查的是:指定类型是否匹配黑板上的 lastResult()。 最近的结果是通过任意绑定操作最后加入的那个对象。
返回类型的处理(Handling of return types)
动作方法通常返回单个领域对象。
允许可空返回类型。返回 null 会触发重规划。 从那一点起可能(也可能不)存在替代路径, 但那条路径不会是规划器原先试图达成的目标。
有一种特殊情况:返回类型本质上可以是联合类型(union type) —— 动作方法可以返回若干类型中的一个或多个。 这是通过让返回类型实现 SomeOf 标记接口来实现的。 该接口的实现可以有多个可空字段,其中任何非空值都会被绑定到黑板, 而该动作的后置条件会包含返回类型的所有可能字段。
例如:
java
// 必须实现 SomeOf 接口
public record FrogOrDog(
@Nullable Frog frog,
@Nullable Dog dog
) implements SomeOf {}
@Agent(description = "Illustrates use of the SomeOf interface")
public class ReturnsFrogOrDog {
@Action
public FrogOrDog frogOrDog() {
return new FrogOrDog(new Frog("Kermit"), null);
}
// 这能工作,是因为返回类型的 frog 字段被设置了
@AchievesGoal(description = "Create a prince from a frog")
@Action
public PersonWithReverseTool toPerson(Frog frog) {
return new PersonWithReverseTool(frog.name());
}
}这能让路由场景以优雅的方式实现。
SomeOf 实例的多个字段可以同时非空,这不是错误 —— 它可能带来最恰当的路由。
路由也可以通过子类型实现,如下例:
java
@Action
public Intent classifyIntent(UserInput userInput) { // ①
return switch (userInput.content()) {
case "billing" -> new BillingIntent();
case "sales" -> new SalesIntent();
case "service" -> new ServiceIntent();
default -> {
logger.warn("Unknown intent: {}", userInput);
yield null;
}
};
}
@Action
public IntentClassificationSuccess billingAction(BillingIntent intent) { // ②
return new IntentClassificationSuccess("billing");
}
@Action
public IntentClassificationSuccess salesAction(SalesIntent intent) {
return new IntentClassificationSuccess("sales");
}
// ...① 分类动作返回父类型
Intent。真实分类大概率会使用 LLM。 ②billingAction等动作接收Intent的子类型,因此只有当分类动作返回了该子类型时才会被调用。
动作方法的实现(Action method implementation)
Embabel 让你能使用常见类型,把 LLM 调用与应用代码无缝整合。 @Action 方法就是普通方法,可以使用你喜欢的任何库或框架。
它唯一特殊之处,是能够使用 OperationContext 参数来访问黑板并调用 LLM。
@AchievesGoal 注解
@AchievesGoal 注解可以加在 @Action 方法上,表示该动作的完成即达成某个特定目标。
@SecureAgentTool 注解
@SecureAgentTool 为一个被暴露为远程 MCP 工具的 Embabel @Action 方法或 @Agent 类声明安全契约。 它接收一个 Spring Security 的 SpEL 表达式,在工具被调用时、Embabel 的 GOAP 规划器执行动作体之前, 针对当前的 Authentication 求值。
放置位置(Placement)
@SecureAgentTool 可以放在 @Agent 类上,统一保护每个 @Action;也可以放在单个方法上做更细粒度的控制。 两者同时存在时,方法级注解优先于类级注解。
类级 —— 一个注解保护该 Agent 中的所有动作,包括在达成目标的动作之前运行的中间步骤:
kotlin
@Agent(description = "Research a topic and return a news digest")
@SecureAgentTool("hasAuthority('news:read')") // ①
class NewsDigestAgent {
@Action
fun extractTopic(userInput: UserInput, context: OperationContext): NewsTopic { ... } // ②
@AchievesGoal(description = "Produce a curated news digest",
export = Export(remote = true, name = "newsDigest",
startingInputTypes = [UserInput::class]))
@Action
fun produceDigest(topic: NewsTopic, context: OperationContext): NewsDigest { ... } // ②
}① 类上的一个注解保护该 Agent 中的每个
@Action。 ②extractTopic与produceDigest都要求news:read。 没有类级保护时,像extractTopic这样的中间动作会在"达成目标动作的安全检查"触发之前自由运行。
方法级覆盖 —— 方法级注解优先于类级表达式,因此可以让某个动作要求更高的权限:
kotlin
@Agent(description = "Market intelligence agent")
@SecureAgentTool("hasAuthority('market:read')") // ①
class MarketIntelligenceAgent {
@Action
fun gatherIntelligence(subject: AnalysisSubject, context: OperationContext): String { ... }
@SecureAgentTool("hasAuthority('market:admin')") // ②
@AchievesGoal(description = "Produce market report")
@Action
fun synthesiseReport(
subject: AnalysisSubject,
rawIntelligence: String,
context: OperationContext
): MarketIntelligenceReport { ... }
}① 所有动作默认要求
market:read。 ②synthesiseReport要求market:admin—— 方法级注解覆盖了类级注解。
支持的表达式(Supported expressions)
任何 Spring Security 的 SpEL 表达式都合法:
| 表达式 | 含义 |
|---|---|
hasAuthority('finance:read') | 主体必须携带该精确权限 |
hasAnyAuthority('finance:read', 'finance:admin') | 主体必须携带所列权限中的至少一个 |
hasRole('ADMIN') | 主体必须携带 ROLE_ADMIN(ROLE_ 前缀会自动添加) |
isAuthenticated() | 任意已认证主体,不限定权限 |
hasAuthority('payments:write') and #request.amount > 10000 | 组合权限与请求参数的条件 |
配置(Setup)
把 MCP 安全 starter 加入你的 pom.xml:
xml
<dependency>
<groupId>com.embabel.agent</groupId>
<artifactId>embabel-agent-starter-mcpserver-security</artifactId>
<version>${embabel-agent.version}</version>
</dependency>该 starter 会自动配置 SecureAgentToolAspect 以及所需的 Spring Security MethodSecurityExpressionHandler。 不需要额外的 @EnableMethodSecurity 注解。
@SecureAgentTool是方法级的安全控制,不是 HTTP 级的。
生产使用时,请把它与一个校验 JWT Bearer token 的 SecurityFilterChain 组合起来, 使未认证请求在到达 GOAP 规划器之前就被拒绝。 通用配置见 Spring Security JWT Resource Server 文档;MCP 专属示例见 MCP Security。
实现 StuckHandler 接口
如果带注解的 Agent 类实现了 StuckHandler 接口,它就能处理"动作自己卡住"的情况。 例如,它可以向黑板添加数据。
示例:
java
@Agent(description = "self unsticking agent")
public class SelfUnstickingAgent implements StuckHandler {
private boolean called = false;
// 因为没有 dog 可以转成 frog,该 Agent 会卡住
@Action
@AchievesGoal(description = "the big goal in the sky")
public Frog toFrog(Dog dog) {
return new Frog(dog.name());
}
// Agent 卡住时,这个方法会被调用
@Override
public StuckHandlerResult handleStuck(AgentProcess agentProcess) {
called = true;
agentProcess.addObject(new Dog("Duke"));
return new StuckHandlerResult(
"Unsticking myself",
this,
StuckHandlingResultCode.REPLAN,
agentProcess
);
}
}进阶用法:嵌套进程(Nested processes)
一个 @Action 方法可以调用另一个 Agent 进程。这通常用于复用那些用 DSL 组合出来的标准流程。
使用 ActionContext.asSubProcess 方法从动作上下文创建子进程。例如:
java
@Action
public ScoredResult<Report, SimpleFeedback> report(
ReportRequest reportRequest,
ActionContext context
) {
return context.asSubProcess(
// 会创建一个强类型的 Agent 子进程
EvaluatorOptimizer.generateUntilAcceptable(
5,
ctx -> ctx.promptRunner()
.withToolGroup(CoreToolGroups.WEB)
.create(String.format("""
Given the topic, generate a detailed report in %d words.
# Topic
%s
# Feedback
%s
""",
reportRequest.words(),
reportRequest.topic(),
ctx.getInput() != null ? ctx.getInput() : "No feedback provided")),
ctx -> ctx.promptRunner()
.withToolGroup(CoreToolGroups.WEB)
.create(String.format("""
Given the topic and word count, evaluate the report and provide feedback
Feedback must be a score between 0 and 1, where 1 is perfect.
# Report
%s
# Report request:
%s
Word count: %d
""",
ctx.getInput().report(),
reportRequest.topic(),
reportRequest.words()))
));
}用 RunSubagent 运行子 Agent
RunSubagent 工具提供了一种便捷方式,让你在 @Action 方法内部运行嵌套 Agent,而无需直接访问 ActionContext。 当你想把工作委派给另一个带 @Agent 注解的类、或某个 Agent 实例时,它特别有用。
运行带 @Agent 注解的实例
当你持有某个带 @Agent 注解的类的实例时,用 RunSubagent.fromAnnotatedInstance():
带注解的实例可以被 Spring 注入到你的 Agent 类中。 由于 @Agent 是 Spring 构造型注解,你可以把一个 Agent 注入另一个 Agent,并把它作为子 Agent 运行 —— 这带来了清晰的关注点分离,同时保持可测试性。
java
@Agent(description = "Outer agent that delegates to an injected subagent")
public class OuterAgent {
private final InnerSubAgent innerSubAgent;
public OuterAgent(InnerSubAgent innerSubAgent) { // ①
this.innerSubAgent = innerSubAgent;
}
@Action
public TaskOutput start(UserInput input) {
return RunSubagent.fromAnnotatedInstance(
innerSubAgent, // ②
TaskOutput.class
);
}
@Action
@AchievesGoal(description = "Processing complete")
public TaskOutput done(TaskOutput output) {
return output;
}
}
@Agent(description = "Inner subagent that processes input")
public class InnerSubAgent {
@Action
public Intermediate stepOne(UserInput input) {
return new Intermediate(input.getContent());
}
@Action
@AchievesGoal(description = "Subagent complete")
public TaskOutput stepTwo(Intermediate data) {
return new TaskOutput(data.value().toUpperCase());
}
}① Spring 通过构造器注入把
InnerSubAgentbean 注入进来。 ② 被注入的实例传给RunSubagent.fromAnnotatedInstance()。
在 Kotlin 中,你可以使用 reified 版本,语法更简洁。
运行 Agent 实例
当你已经持有 Agent 对象时(例如通过编程方式创建、或用 AgentMetadataReader 得到),用 RunSubagent.instance():
java
@Agent(description = "Outer agent with Agent instance")
public class OuterAgentWithAgentInstance {
@Action
public TaskOutput start(UserInput input) {
Agent agent = (Agent) new AgentMetadataReader()
.createAgentMetadata(new InnerSubAgent());
return RunSubagent.instance(agent, TaskOutput.class);
}
@Action
@AchievesGoal(description = "Processing complete")
public TaskOutput done(TaskOutput output) {
return output;
}
}工作原理(How It Works)
RunSubagent 的方法会抛出 SubagentExecutionRequest 异常,由框架捕获。 随后框架把子 Agent 作为当前 Agent 进程内的子进程执行,共享同一个黑板上下文。 子 Agent 那个达成目标动作的结果会被返回给调用方动作。
这种做法有几项优势:
- 更简洁的语法:不需要把
ActionContext传进动作方法; - 类型安全:返回类型在编译期就被强制;
- 可组合:轻松用更简单的 Agent 组合出复杂工作流;
- 可复用:同一个子 Agent 可以在多个上下文中使用。
与 ActionContext.asSubProcess 的对比
RunSubagent 与 ActionContext.asSubProcess 达成同样的结果,只是风格不同:
| 方式 | 何时使用 | 示例 |
|---|---|---|
RunSubagent.fromAnnotatedInstance() | 当你持有带 @Agent 注解的实例,且不需要 ActionContext 时 | RunSubagent.fromAnnotatedInstance(new SubAgent(), Result.class) |
RunSubagent.instance() | 当你持有 Agent 对象时 | RunSubagent.instance(agent, Result.class) |
ActionContext.asSubProcess() | 当你还需要 ActionContext 做其它操作时 | context.asSubProcess(Result.class, agent) |
只有当动作方法只需要委派给子 Agent 时,才用 RunSubagent; 当你还需要额外的上下文操作时,用 ActionContext.asSubProcess()。
动作异常处理(Action Exception Handling)
Action 内部的异常处理由重试策略(Retry Policy) 支配。
除 TransientAiException 之外,下列所有异常都被视为不可重试。 更准确地说,策略按以下顺序对不可重试异常分类:
ReplanRequestedExceptionTerminateActionExceptionTerminateAgentExceptionToolControlFlowSignalNonTransientAiExceptionIllegalArgumentExceptionIllegalStateExceptionUnsupportedOperationExceptionClassCastException
如果某个异常不属于上述任何一类,它会被映射为可重试异常。
框架允许你创建自定义的可重试 / 不可重试异常,从而让开发者完全掌控 Action 的重试行为。
Embabel 提供两种定义自定义可重试与不可重试异常的方式:
- 继承
ActionException—— 便捷的基类,内置重试分类; - 实现标记接口(marker interfaces) —— 对既有异常层次结构提供最大灵活性。
方式 1:继承 ActionException
推荐做法是:可重试失败继承 ActionException.Transient,不可重试失败继承 ActionException.Permanent:
kotlin
import com.embabel.agent.core.ActionException
// 暂时性失败 —— 会被重试
class ApiTimeoutException(message: String, cause: Throwable? = null)
: ActionException.Transient(message, cause)
// 永久性失败 —— 不会被重试
class ValidationException(message: String, cause: Throwable? = null)
: ActionException.Permanent(message, cause)方式 2:实现标记接口
对于既有的异常层次结构,或需要更多控制时,直接实现 Retryable 或 NonRetryable 标记接口:
kotlin
import com.embabel.agent.core.Retryable
import com.embabel.agent.core.NonRetryable
// 暂时性失败 —— 会被重试
class NetworkException(message: String, cause: Throwable? = null)
: RuntimeException(message, cause), Retryable
// 永久性失败 —— 不会被重试
class InvalidOrderException(message: String)
: RuntimeException(message), NonRetryable常见用例
暂时性失败(用 ActionException.Transient 或 Retryable):
- 网络超时
- 限流(429 错误)
- 资源临时不可用
- 连接失败
- 数据库死锁
永久性失败(用 ActionException.Permanent 或 NonRetryable):
- 校验错误
- 违反业务规则
- 参数非法
- 资源未找到(404 错误)
- 认证失败(401 错误)
- 授权失败(403 错误)