主题
25 · 使用 LLM(Working with LLMs)
Embabel 支持 Spring AI 所支持的任何 LLM。实际上,这几乎就是"任何 LLM"。
选择 LLM(Choosing an LLM)
Embabel 鼓励你为每一次 LLM 调用都思考一下模型选择,PromptRunner 接口让这件事很容易。 由于 Embabel 让你把智能体流程拆成多个动作步骤,每一步都可以用更小、更聚焦的提示词和更少的工具 —— 这意味着它可能可以用更小的 LLM 来完成。
考量因素:
- 期望返回类型的复杂度:这通常是判断所需 LLM 质量的良好指标。 小模型很可能应付不了深层嵌套的返回结构。
- 任务性质:不同 LLM 各有所长,请查阅相关文档。 你并不一定需要一个大而昂贵、几乎什么都在行的模型 —— 那会以你的钱包和环境为代价。
- 所需工具调用的复杂度:简单工具调用没问题,但复杂的编排是"你需要一个强模型"的另一个信号。 (这也可能意味着:你应该用 Embabel GOAP 构建一个更精细的流程。)
- 考虑试用跑在 Ollama 或 Docker 下的本地 LLM。
试错是你的朋友。 Embabel 让切换 LLM 很容易:先用"最便宜且可能可行"的那个,不行再换。
为小模型与本地模型调优(Tuning for Smaller and Local Models)
Embabel 的一个核心目标,是让智能体流程在全谱系 LLM 上都跑得好 —— 这样你就可以选择"最便宜、最小、或最私密"的那个能胜任的模型,而不是每次都伸手去拿前沿大模型。
较小的对话模型与前沿模型的行为不同,框架可以针对这些差异做补偿:
- 工具调用后的静默失败:较弱的开放权重模型(例如
gpt-oss-20b、部分 Qwen 变体)在"不知道下一步怎么办"时, 会返回空白文本且不再发起工具调用。若不干预,工具循环会以空内容退出。 打开embabel.agent.platform.toolloop.empty-response.max-retries: 1,框架会回灌一条合成的提醒消息,再给它一次机会。 - 工具名混淆:小模型更经常用近似的名字调用工具。默认的
AutoCorrectionPolicy会通过回灌 "你是不是想用 X?"的建议来处理;如果你的模型需要更多次尝试,调embabel.agent.platform.toolloop.tool-not-found.max-retries。 - 迭代余量:恢复是要花 LLM 调用的。如果你启用了重试策略,请把
embabel.agent.platform.toolloop.max-iterations调大, 以免"需要多一轮往返"的一步耗尽预算。
这些设置默认关闭,所以既有的、使用强模型的部署行为完全不变。 当你选的模型确实受益时,请按部署打开它们。
进阶:自定义 LLM 集成(Custom LLM Integration)
如果你想在运行期使用用户自带的 key 接入标准提供方(Anthropic、OpenAI、DeepSeek、Mistral), 请见 BYOK —— 那是 BYOK 场景的推荐路径。
本节讲的是:为 Embabel 尚未支持的提供方,从零实现 LlmMessageSender。
Embabel 的工具循环是框架无关的,你可以通过实现 LlmMessageSender 接口来接入任意 LLM 提供方。适用于:
- 你想用 Spring AI 不支持的 LLM 提供方;
- 你需要自定义的请求/响应处理;
- 你要接入专有的或内部的 LLM 服务。
LlmMessageSender 接口
核心抽象就是这个函数式接口:
java
@FunctionalInterface
public interface LlmMessageSender {
LlmMessageResponse call(
List<Message> messages,
List<Tool> tools
);
}实现体只负责发起一次 LLM 推理调用并返回响应。 重要的是:它不执行工具 —— 只返回 LLM 提出的工具调用请求,工具执行由 Embabel 的 DefaultToolLoop 负责。
对工具执行还有更进阶的控制 —— 工具可以并行执行,由 ParallelToolLoop 控制。要启用它:
properties
embabel.agent.platform.toolloop.type=parallel完整的工具循环配置项见 ToolLoopConfiguration。
工具名未找到的恢复策略(Tool-Not-Found Recovery Policy)
当 LLM 用一个不存在的名字调用工具时,行为由 ToolNotFoundPolicy 控制。内置两种策略:
AutoCorrectionPolicy(默认) —— 把错误回灌给 LLM 让它自我纠正。 用大小写不敏感的模糊匹配为"幻觉出来的工具名"提供纠正建议(例如ragbot_vectorSearch→ 建议vectorSearch), 多个候选时会全部列出。连续 3 次失败后抛出ToolNotFoundException。ImmediateThrowPolicy—— 立即抛出ToolNotFoundException。
系统级默认是 AutoCorrectionPolicy,以带 @ConditionalOnMissingBean 的 Spring bean 提供。 要全局覆盖,请定义你自己的 ToolNotFoundPolicy bean。
要按每次交互控制,用 PromptRunner 上的 withToolNotFoundPolicy():
java
promptRunner
.withToolNotFoundPolicy(new AutoCorrectionPolicy(5))
.creating(MyOutput.class)
.create(messages);也可以通过实现 ToolNotFoundPolicy 接口来自定义策略:
kotlin
class MyEditDistancePolicy : ToolNotFoundPolicy {
override fun handle(requestedName: String, availableTools: List<Tool>): ToolNotFoundAction {
// 自定义恢复逻辑,例如基于编辑距离的匹配
...
}
}响应类型(Response Types)
LlmMessageResponse 包含:
message:LLM 的响应,表示为 Embabel 的Message;textContent:响应中的文本内容;usage:可选的 token 用量信息。
对于包含工具调用的响应,请返回 AssistantMessageWithToolCalls:
java
public record ToolCall(
String id, // 该工具调用的唯一标识
String name, // 要调用的工具名
String arguments // 工具的 JSON 参数
) {}示例:自定义 LLM 提供方
下面是为一个假想的、基于 HTTP 的 LLM API 实现 LlmMessageSender 的例子:
java
public class MyCustomLlmMessageSender implements LlmMessageSender {
private final HttpClient httpClient;
private final String apiKey;
private final String model;
public MyCustomLlmMessageSender(HttpClient httpClient, String apiKey, String model) {
this.httpClient = httpClient;
this.apiKey = apiKey;
this.model = model;
}
@Override
public LlmMessageResponse call(List<Message> messages, List<Tool> tools) {
// 把 Embabel 消息转换成你的 API 格式
List<Map<String, Object>> apiMessages = messages.stream()
.map(message -> Map.<String, Object>of(
"role", message.getRole().name().toLowerCase(),
"content", message.getTextContent()
))
.toList();
// 把工具定义转换成你的 API 格式
List<Map<String, Object>> apiTools = tools.stream()
.map(tool -> Map.<String, Object>of(
"name", tool.getDefinition().getName(),
"description", tool.getDefinition().getDescription(),
"parameters", tool.getDefinition().getInputSchema().jsonSchema()
))
.toList();
// 发起 API 请求(用你偏好的 HTTP 客户端)
MyApiResponse responseBody = httpClient.post("https://api.my-llm.com/chat")
.header("Authorization", "Bearer " + apiKey)
.body(Map.of(
"model", model,
"messages", apiMessages,
"tools", apiTools.isEmpty() ? null : apiTools
))
.execute(MyApiResponse.class);
// 检查 LLM 是否请求了工具调用
List<ToolCall> toolCalls = null;
if (responseBody.getToolCalls() != null) {
toolCalls = responseBody.getToolCalls().stream()
.map(call -> new ToolCall(
call.getId(),
call.getFunction().getName(),
call.getFunction().getArguments()
))
.toList();
}
Message embabelMessage;
if (toolCalls == null || toolCalls.isEmpty()) {
embabelMessage = new AssistantMessage(
responseBody.getContent() != null ? responseBody.getContent() : ""
);
} else {
embabelMessage = new AssistantMessageWithToolCalls(
responseBody.getContent() != null ? responseBody.getContent() : "",
toolCalls
);
}
Usage usage = null;
if (responseBody.getUsage() != null) {
usage = new Usage(
responseBody.getUsage().getPromptTokens(),
responseBody.getUsage().getCompletionTokens()
);
}
return new LlmMessageResponse(embabelMessage, responseBody.getContent(), usage);
}
}创建 LlmService
要让你的自定义 LLM 能通过 Embabel 的 ModelProvider 使用,请实现 LlmService 接口:
java
public class MyCustomLlmService implements LlmService<MyCustomLlmService> {
private final String name;
private final String provider;
private final HttpClient httpClient;
private final String apiKey;
private final LocalDate knowledgeCutoffDate;
private final List<PromptContributor> promptContributors;
private final PricingModel pricingModel;
public MyCustomLlmService(
String name,
String provider,
HttpClient httpClient,
String apiKey,
LocalDate knowledgeCutoffDate,
PricingModel pricingModel) {
this.name = name;
this.provider = provider;
this.httpClient = httpClient;
this.apiKey = apiKey;
this.knowledgeCutoffDate = knowledgeCutoffDate;
this.promptContributors = knowledgeCutoffDate != null
? List.of(new KnowledgeCutoffDate(knowledgeCutoffDate))
: List.of();
this.pricingModel = pricingModel;
}
@Override
public String getName() { return name; }
@Override
public String getProvider() { return provider; }
@Override
public LocalDate getKnowledgeCutoffDate() { return knowledgeCutoffDate; }
@Override
public List<PromptContributor> getPromptContributors() { return promptContributors; }
@Override
public PricingModel getPricingModel() { return pricingModel; }
@Override
public LlmMessageSender createMessageSender(LlmOptions options) {
return new MyCustomLlmMessageSender(
httpClient,
apiKey,
options.getModel() != null ? options.getModel() : name
);
}
@Override
public MyCustomLlmService withKnowledgeCutoffDate(LocalDate date) {
return new MyCustomLlmService(name, provider, httpClient, apiKey, date, pricingModel);
}
@Override
public MyCustomLlmService withPromptContributor(PromptContributor promptContributor) {
var newContributors = new ArrayList<>(promptContributors);
newContributors.add(promptContributor);
return new MyCustomLlmService(
name, provider, httpClient, apiKey, knowledgeCutoffDate,
newContributors, pricingModel
);
}
}然后把它注册为 Spring bean:
java
@Configuration
public class MyLlmConfiguration {
@Bean
public LlmService<?> myCustomLlm(
HttpClient httpClient,
@Value("${my-llm.api-key}") String apiKey) {
return new MyCustomLlmService(
"my-custom-model",
"MyProvider",
httpClient,
apiKey,
LocalDate.of(2024, 12, 1),
null
);
}
}该 bean 会被自动发现,并通过 ModelProvider 可用。
模型发现与选择的工作方式
应用启动时,ConfigurableModelProvider 会从 Spring 应用上下文中收集所有 LlmService bean。 你的自定义 LLM 通过你在 LlmService 实现上设置的 name 属性被匹配。
按名称:直接用 LlmService 的 name。它适用于 @LlmCall、ai.withLlm() 与 AgenticTool.withLlm():
java
// 在声明式动作里
@LlmCall(llm = "my-custom-model")
String myAction();
// 在命令式动作里
ai.withLlm("my-custom-model")
.create("Tell me a joke", String.class);按角色:在配置里把角色名映射到你的模型名,然后用 # 前缀引用:
yaml
embabel:
models:
default-llm: my-custom-model # ①
llms:
best: my-custom-model # ②
cheapest: my-small-model # ③① 设置"未显式指定模型时"使用的默认 LLM ② 把 best 角色映射到你的自定义模型 ③ 把 cheapest 角色映射到另一个模型
然后用 # 引用角色:
java
// 按角色
@LlmCall(llm = "#best")
String myAction();
// 或编程方式
ai.withLlmByRole("best")
.create("Tell me a joke", String.class);如果 @LlmCall 或 withLlm() 中没有指定 LLM,就会使用配置里的 default-llm。
另一种方式:使用你自己的实现
如果你需要对 LLM 操作层本身有更多控制,可以扩展 ToolLoopLlmOperations:
java
public class MyCustomLlmOperations extends ToolLoopLlmOperations {
private final HttpClient httpClient;
private final String apiKey;
public MyCustomLlmOperations(
HttpClient httpClient,
String apiKey,
ModelProvider modelProvider,
ToolDecorator toolDecorator,
Validator validator) {
super(modelProvider, toolDecorator, validator);
this.httpClient = httpClient;
this.apiKey = apiKey;
}
@Override
protected LlmMessageSender createMessageSender(LlmService<?> llm, LlmOptions options) {
return new MyCustomLlmMessageSender(
httpClient,
apiKey,
options.getModel() != null ? options.getModel() : "default-model"
);
}
}ToolLoopLlmOperations 基类提供了若干扩展点:
createMessageSender():创建 LLM 通信层;createOutputConverter():把 LLM 响应解析成类型化对象;sanitizeStringOutput():清洗原始文本响应;emitCallEvent():发出可观测性事件。
关键实现说明
- 你的 sender 不执行工具调用。只需返回工具调用请求 —— Embabel 的工具循环负责执行与续跑。
- 同时处理"有工具"与"无工具"两种响应。纯文本返回
AssistantMessage;LLM 想调工具时返回AssistantMessageWithToolCalls。 - 可用时请带上 usage 信息。这让成本追踪与可观测性成为可能。
- 消息类型很重要。工具循环期望特定消息类型:
UserMessage:用户输入SystemMessage:system promptAssistantMessage:LLM 文本响应AssistantMessageWithToolCalls:带工具请求的 LLM 响应ToolResultMessage:工具执行后返回给 LLM 的结果
进阶:自定义 Embedding 服务
正如你可以接入自定义 LLM,你也可以实现不依赖 Spring AI 的自定义 embedding 服务。适用于:
- 你想用 Spring AI 不支持的 embedding 提供方;
- 你需要对 embedding 做自定义的前/后处理;
- 你要接入专有的或内部的 embedding API。
EmbeddingService 接口
EmbeddingService 接口是框架无关的。与 SpringAiEmbeddingService 不同,自定义实现不必包装 Spring AI 的 EmbeddingModel:
java
public interface EmbeddingService {
float[] embed(String text);
List<float[]> embed(List<String> texts);
int getDimensions();
String getName();
String getProvider();
}示例:自定义 Embedding 提供方
下面是为一个基于 HTTP 的 embedding API 实现 EmbeddingService 的例子:
java
public class MyCustomEmbeddingService implements EmbeddingService {
private final String name;
private final String provider;
private final int dimensions;
private final HttpClient httpClient;
private final String apiKey;
public MyCustomEmbeddingService(
String name,
String provider,
int dimensions,
HttpClient httpClient,
String apiKey) {
this.name = name;
this.provider = provider;
this.dimensions = dimensions;
this.httpClient = httpClient;
this.apiKey = apiKey;
}
@Override
public String getName() { return name; }
@Override
public String getProvider() { return provider; }
@Override
public int getDimensions() { return dimensions; }
@Override
public float[] embed(String text) {
return embed(List.of(text)).get(0);
}
@Override
public List<float[]> embed(List<String> texts) {
MyEmbeddingResponse response = httpClient
.post("https://api.my-embeddings.com/embed")
.header("Authorization", "Bearer " + apiKey)
.body(Map.of("texts", texts, "model", name))
.execute(MyEmbeddingResponse.class);
return response.getEmbeddings();
}
}注册为 Spring bean
把你的自定义 embedding 服务注册为 Spring bean,它就会被自动发现:
java
@Configuration
public class MyEmbeddingConfiguration {
@Bean
public EmbeddingService myCustomEmbeddings(
HttpClient httpClient,
@Value("${my-embeddings.api-key}") String apiKey) {
return new MyCustomEmbeddingService(
"my-custom-embeddings",
"MyProvider",
384,
httpClient,
apiKey
);
}
}发现与选择
自定义 embedding 服务遵循与 LLM 相同的发现与选择模式。
按名称 —— 用 ai.withEmbeddingService() 加你实现里的 name:
java
ai.withEmbeddingService("my-custom-embeddings")
.embed("Hello world");按角色 —— 在配置里把角色名映射到你的 embedding 服务:
yaml
embabel:
models:
default-embedding-model: my-custom-embeddings # ①
embedding-services:
cheapest: my-custom-embeddings # ②① 设置默认 embedding 服务 ② 把 cheapest 角色映射到你的自定义 embedding 服务
进阶:Anthropic 的显式缓存(Caching)
很多提供方的缓存是内部隐式管理的,而 Anthropic 公开了用于显式 prompt 缓存控制的 API。 这让你可以针对"长提示词、工具很多、或长对话"的应用优化成本与延迟。
动机
Anthropic 的 prompt 缓存特性带来显著收益:
- 省钱:缓存读取的成本比常规输入 token 低 90%;
- 降延迟:缓存内容处理更快;
- 理想场景:长 system prompt、大量工具定义、多轮对话。
没有缓存时,每次 API 调用都要从头处理整个提示词。 有了缓存,重复内容(system prompt、工具、对话历史)就能被缓存并在请求之间复用。
工作原理
Anthropic 缓存的是提示词上下文的尾部(trailing portion)。 缓存通过内容哈希的精确匹配来识别 —— 被缓存部分的任何改动都会使缓存失效。
关键概念:
- 缓存创建(Cache creation):首次见到该内容时写入缓存,相比常规输入 token 有 25% 的溢价(5 分钟 TTL);
- 缓存读取(Cache reads):后续内容匹配的请求从缓存读取,成本为常规输入 token 的 10%;
- 缓存 TTL:5 分钟(默认)或 1 小时(更贵,创建成本更高);
- 最小尺寸:较老模型为 1024 token,Claude Sonnet 4.5 及更新模型为 4096 token。
缓存策略
Embabel 通过 AnthropicCachingConfig 提供多种缓存策略。
System Prompt 缓存 —— 缓存 system prompt,供多个请求复用:
java
AnthropicCachingConfig cachingConfig = new AnthropicCachingConfig();
cachingConfig.setSystemPrompt(true);
LlmOptions options = LlmOptions.withDefaultLlm();
options = withAnthropicCaching(options, cachingConfig);工具缓存 —— 当工具很多、或工具 schema 很大时缓存工具定义:
java
AnthropicCachingConfig cachingConfig = new AnthropicCachingConfig();
cachingConfig.setTools(true);
LlmOptions options = LlmOptions.withDefaultLlm();
options = withAnthropicCaching(options, cachingConfig);System + 工具缓存 —— 组合两种策略:
java
AnthropicCachingConfig cachingConfig = new AnthropicCachingConfig();
cachingConfig.setSystemPrompt(true);
cachingConfig.setTools(true);
LlmOptions options = LlmOptions.withDefaultLlm();
options = withAnthropicCaching(options, cachingConfig);对话历史缓存 —— 为长多轮对话缓存对话历史:
java
AnthropicCachingConfig cachingConfig = new AnthropicCachingConfig();
cachingConfig.setConversationHistory(true);
LlmOptions options = LlmOptions.withDefaultLlm();
options = withAnthropicCaching(options, cachingConfig);进阶配置
按消息类型设置最小内容长度 —— 依据内容长度控制哪些消息有资格被缓存:
java
AnthropicCachingConfig cachingConfig = new AnthropicCachingConfig();
cachingConfig.setSystemPrompt(true);
cachingConfig.messageTypeMinContentLength(MessageRole.SYSTEM, 1024);
cachingConfig.messageTypeMinContentLength(MessageRole.USER, 512);
LlmOptions options = LlmOptions.withDefaultLlm();
options = withAnthropicCaching(options, cachingConfig);按消息类型设置 TTL —— 为每种消息类型设置缓存 TTL(默认为 5 分钟):
java
AnthropicCachingConfig cachingConfig = new AnthropicCachingConfig();
cachingConfig.setSystemPrompt(true);
cachingConfig.messageTypeTtl(MessageRole.SYSTEM, AnthropicCacheTtl.ONE_HOUR);
LlmOptions options = LlmOptions.withDefaultLlm();
options = withAnthropicCaching(options, cachingConfig);读取缓存指标
Embabel 提供了扩展方法,用于从 Usage 对象读取 Anthropic 专属的缓存指标:
java
import static com.embabel.agent.config.models.anthropic.AnthropicUsage.*;
AssistantMessage response = promptRunner.respond(messages);
Usage usage = response.getUsage();
// 检查缓存是被创建还是被读取
boolean cacheCreated = hasAnthropicCacheCreation(usage);
boolean cacheRead = hasAnthropicCacheRead(usage);
// 取 token 计数
Integer creationTokens = anthropicCacheCreationTokens(usage);
Integer readTokens = anthropicCacheReadTokens(usage);
// 取用于日志的摘要字符串
String summary = anthropicCacheSummary(usage);
// 输出示例:"cache[creation=1061, read=0]"最佳实践
- 缓存长而稳定的内容:不常变化的 system prompt 与工具定义是理想候选;
- 注意最小尺寸:内容必须达到最小 token 要求(视模型而定,1024 或 4096);
- 监控缓存指标:用缓存扩展方法跟踪命中率、验证节省效果;
- 权衡 TTL 与成本:1 小时 TTL 创建成本更高,但对长会话更好;
- 上线前先测试:缓存行为会随提示词结构与使用模式而变化。
参考
Anthropic prompt 缓存的完整细节见:https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching
进阶特性:原生结构化输出(Native Structured Output)
原生结构化输出让模型提供方直接强制一个 JSON Schema,而不是只依赖提示词指令与 Embabel 的对象构造。 schema 生成与对象绑定仍然由 Embabel 负责;原生路径只是一个优化与兼容特性, 仅在该次调用被显式支持且安全时才会使用。
载荷格式(Payload Format)
对 OpenAI 兼容提供方,Spring AI 会把 Embabel 的 schema 包装进 response_format 的 JSON schema 载荷里。 例如一个 MonthItem 对象可能产生这样的请求片段:
json
{
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "MonthItem",
"strict": true,
"schema": {
"type": "object",
"properties": {
"name": { "type": "string" },
"temperature": { "type": "integer" }
},
"required": ["name", "temperature"],
"additionalProperties": false
}
}
}
}OpenAI 会在生成之前校验这个 schema。 如果 schema 对原生路径不合法,请求会在模型产出任何输出之前就失败。
各方职责(Participants Roles)
- Embabel 生成 JSON Schema、决定该次调用是否启用原生路径,并把最终响应解析成所请求的对象;
- Spring AI 把 Embabel 的 schema 包装成提供方专属的格式选项(例如 OpenAI 的
response_format),并序列化 HTTP 请求; - 提供方(例如 OpenAI)校验原生结构化输出载荷,并返回符合所接受 schema 的输出。
配置与 API
原生结构化输出会在模型元数据中声明:
yaml
native_support_defaults:
structured_output:
supported: true
strategy: response_format
strict: false
prompt_instructions: include对某次具体调用,通过 LlmOptions 启用:
java
PromptRunner runner = ai.withLlm(
withNativeStructuredOutput(
LlmOptions.fromCriteria(DefaultModelSelectionCriteria.INSTANCE),
NativeStructuredOutputMode.ENABLED
)
);
MonthItem result = runner.createObject(prompt, MonthItem.class);NativeStructuredOutputMode.ENABLED:在模型能力与 schema 兼容性允许时,尝试原生路径;NativeStructuredOutputMode.DISABLED:强制走 Embabel 的常规对象构造路径;NativeStructuredOutputMode.DEFAULT:由 Embabel 根据模型能力、API 形态与 schema 兼容性自行决定。
对 Java 对象,请显式标记必填的引用字段:
java
static class MonthItem {
@JsonProperty(required = true)
private String name;
@JsonProperty(required = true)
private Integer temperature;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Integer getTemperature() {
return temperature;
}
public void setTemperature(Integer temperature) {
this.temperature = temperature;
}
}限制(Limitations)
- 必填字段:OpenAI 的原生结构化输出要求
required包含每一个属性。 Java 的可选引用字段除非被注解(例如@JsonProperty(required = true)或@NotNull),否则不会被视为必填。 - 可选值:提供方原生的"可选"通常需要可空的必填属性(例如
type: ["string", "null"]), 而不是把该属性从required里去掉。 - 对象数组:部分提供方(据 Spring AI 文档,包括 OpenAI)原生不支持对象数组。 对不支持的形状,Embabel 会回退到常规的对象构造。
- 流式:Spring AI 目前不支持原生结构化输出的流式模式。
...IfPossibleAPI:createObjectIfPossible及相关路径使用MaybeReturn语义,始终留在 Embabel 的回退对象构造路径上。- 提供方覆盖范围:OpenAI 兼容的
response_format是主要受支持的路径; Anthropic 的原生结构化输出目前默认关闭,直到其语义被验证为止。