在“AI + 业务系统”真正落地的过程中,很多团队会发现一个典型问题:只接一个大模型接口,很容易做出 Demo;但要做成一个能承载真实流量、具备上下文记忆、可观测、可扩展、可治理的 Agent 系统,难度会陡增。
以“智能行程规划”为例,用户不会只问一句“帮我规划去北京的旅行”。真实场景中的对话往往是连续、多轮、动态变化的:
- “我五一想去杭州玩三天,预算 3000。”
- “不要太早起,酒店离西湖近一点。”
- “第二天下雨的话,把户外项目换成室内。”
- “另外我不吃辣,同行还有一位老人。”
这类需求天然依赖上下文、用户偏好、实时外部数据和多步骤决策,因此非常适合 Agent 化建模。但如果没有工程化设计,系统很快会暴露出几个问题:
- 对话上下文无限增长,Prompt 成本失控;
- 多实例部署后,记忆状态不一致;
- 外部地图、天气、景点服务抖动导致整体雪崩;
- 高并发下
Redis、LLM、下游接口成为瓶颈;
- 缺少可观测性,线上问题无法定位。
本文不再停留在“能跑起来”的示例层面,而是从架构、原理、工程实现和生产优化四个维度,完整讲解如何基于 Spring AI 构建一个可用于真实业务的智能行程规划 Agent。
文章目标如下:
- 深入解释 Spring AI 中聊天记忆、Prompt 组装、Agent 编排的核心原理;
- 给出适用于高并发场景的系统架构和工程治理方案;
- 提供接近生产可用的代码骨架,而不是仅供演示的伪代码;
- 用真实业务案例说明如何把“对话式 AI”落地为“可交付的业务能力”。
2. 业务目标与真实挑战
2.1 业务目标
一个真正有价值的“智能行程规划 Agent”,至少要满足以下能力:
- 理解多轮上下文,而不是只看当前一句话;
- 识别长期偏好,例如预算、出行方式、饮食禁忌、酒店档次;
- 结合实时外部信息,例如天气、路况、景区开放时间;
- 输出结构化规划结果,便于前端渲染、改写和二次操作;
- 在高峰时段保持可用,不能因为某个外部服务超时就整体失效。
2.2 典型业务难点
1. 上下文不是“越多越好”
很多初学者会把所有对话历史都拼到 Prompt 中。这样做短期有效,长期一定出问题:
- Token 成本持续上升;
- 响应延迟变长;
- 历史噪声干扰当前意图;
- 模型更容易“遗忘重点”。
因此,记忆系统必须分层,而不是简单堆历史消息。
2. 用户输入并不总是完整
例如“帮我安排上海两天自由行”,系统其实缺少关键信息:
- 出发地;
- 人数;
- 日期;
- 预算;
- 是否亲子;
- 是否自驾;
- 是否已有酒店。
Agent 需要先补齐缺失参数,再规划,而不是一次性“脑补”所有内容。
3. 行程规划本质上是“推理 + 工具调用 + 约束求解”
生成一份可执行的行程,不只是自然语言输出,更像一个小型决策系统:
- 需要查天气;
- 需要查 POI;
- 需要估算路程与时长;
- 需要判断营业时间;
- 需要做预算约束;
- 需要在“用户偏好”与“实际可达性”之间平衡。
4. 生产环境必须面对不稳定
外部依赖经常抖动:
- 天气接口偶发超时;
- 地图服务限流;
- 景点数据不全;
- LLM 响应慢或配额不足。
如果没有超时、熔断、缓存、降级和异步化,系统会非常脆弱。
3. 核心原理:Spring AI 中的记忆、上下文与 Agent
3.1 Chat Memory 的本质
从原理上看,聊天记忆并不是“大模型真的记住了你”,而是应用层在每次请求前,将与当前任务相关的历史上下文重新组织后送入模型。
也就是说,所谓“记忆”本质上分为两部分:
- 存储层:把历史消息、摘要、偏好、事实存下来;
- 编排层:在当前请求到来时,决定把哪些内容送入 Prompt。
Spring AI 在这件事上提供了两个关键能力:
ChatClient:负责构建 Prompt、调用模型、处理返回结果;
ChatMemory 或记忆仓储能力:负责保存和读取消息历史。
但在真实项目中,我们通常不会只保留“逐条消息”,而会做三层记忆。
3.2 记忆分层模型
1. 会话工作记忆
保存最近 N 轮消息,用于维持当前对话连续性。
适合存储:
- 最近几轮用户追问;
- 当前任务上下文;
- 刚刚确定的限制条件。
特点:
2. 用户长期画像记忆
保存跨会话稳定存在的偏好和事实。
例如:
- 偏好高铁而非飞机;
- 酒店预算 500-800 元;
- 同行有老人;
- 忌口辛辣;
- 偏好慢节奏、不赶行程。
特点:
3. 摘要记忆
当会话过长时,系统不再保存全部明细,而是周期性做摘要压缩。
例如把 20 轮对话压缩成:
用户计划五一期间去杭州三日游,预算 3000 元,两人同行,其中一位老人,不接受高强度路线,偏好西湖和人文景点,希望酒店靠近西湖,第二天如遇下雨优先安排室内活动。
特点:
- 大幅降低 Token 消耗;
- 保留高价值事实;
- 适合长会话。
因此,生产系统中的“聊天记忆”通常不是一个 List,而是如下组合:
工作记忆(近 N 轮) + 摘要记忆(压缩上下文) + 用户画像(长期偏好)
3.3 Agent 的执行本质
行程规划 Agent 不是单次“问答”,而是一条有状态的执行链路:
- 接收用户输入;
- 识别意图;
- 读取会话记忆和用户画像;
- 判断信息是否充分;
- 必要时追问补齐参数;
- 调用天气、地图、POI、预算等工具;
- 生成候选规划;
- 输出自然语言答复和结构化结果;
- 回写消息、摘要和画像。
这意味着系统设计不能只围绕 Controller,而要围绕“Agent 编排链路”展开。
4. 适合生产环境的总体架构
4.1 架构设计目标
我们希望系统具备以下特性:
- 应用实例无状态,支持水平扩容;
- 记忆存储外置,支持多实例共享;
- 工具调用可隔离、可降级、可缓存;
- 同步链路只做“用户必须等待”的事情;
- 重任务异步化,避免请求线程被长时间占用;
- 全链路可观测,便于分析延迟与失败原因。
4.2 推荐架构
4.3 架构分层说明
接入层
负责统一鉴权、限流、日志透传、灰度发布和 API 聚合。
Agent 编排层
系统核心,负责:
- Prompt 组装;
- 记忆装载;
- 工具调用;
- 结果整形;
- 降级兜底;
- 事件投递。
记忆层
分离“短期会话记忆”和“长期画像记忆”:
Redis:会话消息、摘要、临时状态;
- MySQL /
Redis Hash:用户偏好、稳定画像;
- 对象存储或 ES:审计日志、调试记录、长文本归档。
工具层
通过统一的 Tool Adapter 接口封装地图、天气、景点等能力,避免业务层散落大量外部调用逻辑。
异步任务层
适合处理:
- 大批量备选路线计算;
- 行程重排;
- 复杂预算估算;
- 规划结果落库;
- 用户通知推送。
观测层
必须监控:
- LLM 平均耗时、P95、P99;
- Prompt Token / Completion Token;
Redis 命中率;
- 外部工具成功率;
- 熔断触发次数;
- 降级响应占比。
5. 领域建模:不要把 Agent 只当成聊天机器人
5.1 领域对象设计
如果系统只返回一句自然语言,前端很难渲染、保存、编辑和重算。因此建议同时输出结构化对象。
核心领域对象至少包括:
TravelRequest
TravelConstraint
UserProfile
DayPlan
ActivityPlan
RoutePlan
BudgetPlan
TravelPlanResult
示例:
public record TravelRequest(
String sessionId,
String userId,
String userMessage,
LocalDate startDate,
Integer days,
String destination,
Integer travelers,
BigDecimal budget,
TravelConstraint constraint
) {}
public record TravelConstraint(
boolean withElderly,
boolean withChildren,
boolean avoidSpicyFood,
boolean preferSlowPace,
boolean preferPublicTransport,
String hotelArea,
List<String> mustVisitPoi
) {}
结构化建模的好处很明显:
- 便于参数补齐;
- 便于规则校验;
- 便于结果落库;
- 便于前端二次编辑;
- 便于在异步任务中复用。
5.2 对话驱动与结构化驱动结合
推荐采用“双轨输出”:
- 面向用户:输出自然语言解释;
- 面向系统:输出 JSON 结构化行程。
这样既保证交互体验,又方便业务系统继续处理。
6. 技术选型建议
下面给出一套适合大多数中大型项目的技术栈建议。
| 组件 |
推荐方案 |
说明 |
| 应用框架 |
Spring Boot 3.x |
与 Spring AI 生态兼容 |
| AI 编排 |
Spring AI |
统一 ChatClient、Tool、Advisor 机制 |
| 模型接入 |
OpenAI 兼容接口 / 企业模型网关 |
屏蔽底层模型差异 |
| 会话记忆 |
Redis |
高性能、易扩展、适合短期记忆 |
| 用户画像 |
MySQL + Redis Cache |
强一致主存 + 高速缓存 |
| 消息队列 |
Kafka / RocketMQ |
解耦异步规划与回写 |
| 熔断限流 |
Resilience4j + Bucket4j |
稳定性治理 |
| 观测 |
Micrometer + Prometheus + Grafana |
指标采集与告警 |
| 日志链路 |
Sleuth / OpenTelemetry |
Trace 级诊断 |
| 容器化 |
Docker + Kubernetes |
弹性扩缩容 |
设计原则是:
- “状态外置”优先;
- “同步尽量短、异步承担重活”;
- “外部服务必须隔离”;
- “所有 Agent 行为都要可观测”。
7. 生产级实现方案
下面给出一套更接近生产的代码骨架。重点不在于每一行都能直接复制运行,而在于体现真实项目中该如何组织职责。
7.1 Maven 依赖
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-vector-store-redis</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-spring-boot3</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
<dependency>
<groupId>com.github.vladimir-bukhtoyarov</groupId>
<artifactId>bucket4j-core</artifactId>
</dependency>
</dependencies>
7.2 配置文件
server:
port: 8080
spring:
application:
name: travel-agent-service
data:
redis:
host: 127.0.0.1
port: 6379
timeout: 2s
kafka:
bootstrap-servers: 127.0.0.1:9092
consumer:
group-id: travel-agent-worker
auto-offset-reset: latest
ai:
openai:
api-key: ${OPENAI_API_KEY}
base-url: ${OPENAI_BASE_URL:https://api.openai.com}
chat:
options:
model: gpt-4o-mini
temperature: 0.2
management:
endpoints:
web:
exposure:
include: health,info,prometheus,metrics
metrics:
tags:
application: ${spring.application.name}
travel:
memory:
max-recent-messages: 12
summary-threshold: 20
ttl-hours: 168
planner:
route-timeout-ms: 1200
weather-timeout-ms: 800
poi-timeout-ms: 1000
7.3 统一请求对象
public record ChatRequestCommand(
@NotBlank String sessionId,
@NotBlank String userId,
@NotBlank String message,
String traceId
) {}
public record ChatReply(
String sessionId,
String reply,
TravelPlanResult planResult,
boolean degraded,
String traceId
) {}
7.4 会话记忆仓储设计
真实系统不建议把 Redis 当作简单字符串缓存使用,而应设计明确的 Key 模型。
建议 Key 规划:
travel:session:{sessionId}:messages
travel:session:{sessionId}:summary
travel:user:{userId}:profile
travel:session:{sessionId}:draft-plan
代码示例:
public interface ConversationMemoryRepository {
List<MessageRecord> loadRecentMessages(String sessionId, int limit);
void appendMessage(String sessionId, MessageRecord message);
Optional<String> loadSummary(String sessionId);
void saveSummary(String sessionId, String summary);
void expireSession(String sessionId, Duration ttl);
}
@Service
public class RedisConversationMemoryRepository implements ConversationMemoryRepository {
private static final String MESSAGE_KEY_PREFIX = "travel:session:%s:messages";
private static final String SUMMARY_KEY_PREFIX = "travel:session:%s:summary";
private final StringRedisTemplate redisTemplate;
private final ObjectMapper objectMapper;
public RedisConversationMemoryRepository(StringRedisTemplate redisTemplate, ObjectMapper objectMapper) {
this.redisTemplate = redisTemplate;
this.objectMapper = objectMapper;
}
@Override
public List<MessageRecord> loadRecentMessages(String sessionId, int limit) {
String key = MESSAGE_KEY_PREFIX.formatted(sessionId);
List<String> values = redisTemplate.opsForList().range(key, -limit, -1);
if (values == null || values.isEmpty()) {
return List.of();
}
return values.stream().map(this::deserialize).toList();
}
@Override
public void appendMessage(String sessionId, MessageRecord message) {
String key = MESSAGE_KEY_PREFIX.formatted(sessionId);
redisTemplate.opsForList().rightPush(key, serialize(message));
}
@Override
public Optional<String> loadSummary(String sessionId) {
String key = SUMMARY_KEY_PREFIX.formatted(sessionId);
return Optional.ofNullable(redisTemplate.opsForValue().get(key));
}
@Override
public void saveSummary(String sessionId, String summary) {
String key = SUMMARY_KEY_PREFIX.formatted(sessionId);
redisTemplate.opsForValue().set(key, summary);
}
@Override
public void expireSession(String sessionId, Duration ttl) {
redisTemplate.expire(MESSAGE_KEY_PREFIX.formatted(sessionId), ttl);
redisTemplate.expire(SUMMARY_KEY_PREFIX.formatted(sessionId), ttl);
}
private String serialize(MessageRecord message) {
try {
return objectMapper.writeValueAsString(message);
} catch (JsonProcessingException e) {
throw new IllegalStateException("serialize message failed", e);
}
}
private MessageRecord deserialize(String value) {
try {
return objectMapper.readValue(value, MessageRecord.class);
} catch (JsonProcessingException e) {
throw new IllegalStateException("deserialize message failed", e);
}
}
}
public record MessageRecord(
String role,
String content,
Instant timestamp
) {
public static MessageRecord user(String content) {
return new MessageRecord("user", content, Instant.now());
}
public static MessageRecord assistant(String content) {
return new MessageRecord("assistant", content, Instant.now());
}
}
这一层的关键点有三个:
- 不把
Redis 访问散落在业务代码中;
- 消息、摘要、画像分别建模;
- TTL 统一控制,便于过期治理。
7.5 用户画像服务
长期偏好不应该只靠模型“猜”,而应该显式抽取并保存。
public record UserProfile(
String userId,
Integer budgetMin,
Integer budgetMax,
boolean avoidSpicyFood,
boolean withElderly,
boolean preferSlowPace,
String preferredTransport,
String preferredHotelArea
) {}
public interface UserProfileService {
Optional<UserProfile> findByUserId(String userId);
void mergeProfile(String userId, ExtractedPreference preference);
}
在每次对话完成后,可以通过一个轻量级“偏好抽取 Prompt”把本轮新增事实提取出来,再做结构化合并,而不是把所有信息都永久存入消息历史。
7.6 Prompt 组装器
生产场景中,Prompt 组装不应写死在 Service 中,而应独立封装,便于灰度和演进。
@Component
public class TravelPromptBuilder {
public String buildSystemPrompt(UserProfile profile, Optional<String> summary) {
return """
你是企业级智能行程规划助手。
你的目标不是泛泛聊天,而是输出可执行、可解释、可调整的出行建议。
约束要求:
1. 优先保证安全、可达性和时间合理性;
2. 若信息不足,先追问关键缺失字段;
3. 行程强度需匹配用户画像;
4. 输出时同时给出自然语言说明和结构化建议;
5. 如果工具数据不完整,要显式说明不确定性。
用户画像:
%s
历史摘要:
%s
""".formatted(renderProfile(profile), summary.orElse("暂无摘要"));
}
private String renderProfile(UserProfile profile) {
return """
- 预算范围:%s - %s
- 忌辣:%s
- 有老人同行:%s
- 偏好慢节奏:%s
- 偏好交通:%s
- 偏好酒店区域:%s
""".formatted(
profile.budgetMin(),
profile.budgetMax(),
profile.avoidSpicyFood(),
profile.withElderly(),
profile.preferSlowPace(),
profile.preferredTransport(),
profile.preferredHotelArea()
);
}
}
7.7 工具层封装
不建议在 Agent Service 中直接写 RestTemplate.getForObject(...)。正确方式是建立工具适配层。
public interface WeatherTool {
WeatherSnapshot query(String city, LocalDate date);
}
public interface MapTool {
RouteSnapshot route(String from, String to, String travelMode);
}
public interface PoiTool {
List<PoiSnapshot> search(String city, List<String> tags);
}
示例实现:
@Service
public class CachedWeatherTool implements WeatherTool {
private final WeatherClient weatherClient;
private final Cache<String, WeatherSnapshot> cache;
public CachedWeatherTool(WeatherClient weatherClient) {
this.weatherClient = weatherClient;
this.cache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(10))
.build();
}
@Override
@CircuitBreaker(name = "weatherTool", fallbackMethod = "fallback")
@TimeLimiter(name = "weatherTool")
public WeatherSnapshot query(String city, LocalDate date) {
String key = city + ":" + date;
return cache.get(key, k -> weatherClient.fetch(city, date));
}
public WeatherSnapshot fallback(String city, LocalDate date, Throwable ex) {
return new WeatherSnapshot(city, date, "UNKNOWN", "天气服务暂不可用,使用保守规划策略");
}
}
生产化重点在于:
- Tool 层统一超时;
- Tool 层统一熔断;
- Tool 层支持缓存;
- Tool 层返回“可降级结果”,而不是简单抛异常。
7.8 Agent 编排服务
下面是核心服务的建议实现。
@Service
public class TravelAgentService {
private final ChatClient chatClient;
private final ConversationMemoryRepository memoryRepository;
private final UserProfileService userProfileService;
private final TravelPromptBuilder promptBuilder;
private final TravelPlanningEngine planningEngine;
private final ApplicationEventPublisher eventPublisher;
private final TravelProperties properties;
public TravelAgentService(
ChatClient.Builder chatClientBuilder,
ConversationMemoryRepository memoryRepository,
UserProfileService userProfileService,
TravelPromptBuilder promptBuilder,
TravelPlanningEngine planningEngine,
ApplicationEventPublisher eventPublisher,
TravelProperties properties
) {
this.chatClient = chatClientBuilder.build();
this.memoryRepository = memoryRepository;
this.userProfileService = userProfileService;
this.promptBuilder = promptBuilder;
this.planningEngine = planningEngine;
this.eventPublisher = eventPublisher;
this.properties = properties;
}
public ChatReply chat(ChatRequestCommand command) {
UserProfile profile = userProfileService.findByUserId(command.userId())
.orElseGet(() -> UserProfiles.defaultProfile(command.userId()));
List<MessageRecord> recentMessages = memoryRepository
.loadRecentMessages(command.sessionId(), properties.memory().maxRecentMessages());
Optional<String> summary = memoryRepository.loadSummary(command.sessionId());
String systemPrompt = promptBuilder.buildSystemPrompt(profile, summary);
List<Message> messages = toModelMessages(systemPrompt, recentMessages, command.message());
TravelPlanResult planResult = planningEngine.tryBuildPlan(command.message(), profile);
String userPrompt = mergeUserPrompt(command.message(), planResult);
String reply = chatClient.prompt()
.system(systemPrompt)
.messages(messages)
.user(userPrompt)
.call()
.content();
memoryRepository.appendMessage(command.sessionId(), MessageRecord.user(command.message()));
memoryRepository.appendMessage(command.sessionId(), MessageRecord.assistant(reply));
memoryRepository.expireSession(command.sessionId(), Duration.ofHours(properties.memory().ttlHours()));
eventPublisher.publishEvent(new ConversationFinishedEvent(
command.sessionId(), command.userId(), command.message(), reply
));
return new ChatReply(
command.sessionId(),
reply,
planResult,
planResult.degraded(),
command.traceId()
);
}
private List<Message> toModelMessages(String systemPrompt, List<MessageRecord> recentMessages, String userMessage) {
List<Message> messages = new ArrayList<>();
messages.add(new SystemMessage(systemPrompt));
for (MessageRecord record : recentMessages) {
if ("user".equals(record.role())) {
messages.add(new UserMessage(record.content()));
} else {
messages.add(new AssistantMessage(record.content()));
}
}
messages.add(new UserMessage(userMessage));
return messages;
}
private String mergeUserPrompt(String userMessage, TravelPlanResult planResult) {
if (planResult == null) {
return userMessage;
}
return """
用户原始需求:
%s
当前已生成的结构化规划:
%s
请基于以上内容,用专业、清晰、可执行的方式向用户回复。
""".formatted(userMessage, planResult.toCompactJson());
}
}
这段实现相比传统 Demo 有几个关键升级:
- 记忆读取、画像读取、Prompt 拼装职责分离;
- 结构化规划先生成,再交给模型做自然语言解释;
- 对话结束后通过事件机制异步做画像抽取和摘要更新;
- 应用实例保持无状态,适合水平扩展。
7.9 规划引擎
规划引擎负责将“聊天问题”转成“可执行计划”,这是业务核心,不应全部交给大模型。
@Service
public class TravelPlanningEngine {
private final WeatherTool weatherTool;
private final MapTool mapTool;
private final PoiTool poiTool;
public TravelPlanningEngine(WeatherTool weatherTool, MapTool mapTool, PoiTool poiTool) {
this.weatherTool = weatherTool;
this.mapTool = mapTool;
this.poiTool = poiTool;
}
public TravelPlanResult tryBuildPlan(String userMessage, UserProfile profile) {
ParsedIntent intent = IntentParser.parse(userMessage, profile);
if (!intent.readyForPlanning()) {
return TravelPlanResult.askForMoreInformation(intent.missingFields());
}
WeatherSnapshot weather = weatherTool.query(intent.destination(), intent.startDate());
List<PoiSnapshot> pois = poiTool.search(intent.destination(), intent.tags());
List<DayPlan> days = new ArrayList<>();
for (int day = 0; day < intent.days(); day++) {
days.add(planSingleDay(intent, profile, weather, pois, day));
}
return TravelPlanResult.success(days, weather.status().equals("UNKNOWN"));
}
private DayPlan planSingleDay(
ParsedIntent intent,
UserProfile profile,
WeatherSnapshot weather,
List<PoiSnapshot> pois,
int dayIndex
) {
List<ActivityPlan> activities = DayPlanner.compose(intent, profile, weather, pois, dayIndex);
return new DayPlan(dayIndex + 1, activities);
}
}
这里体现的工程思想是:
- 大模型负责理解与表达;
- 规则引擎和工具层负责约束求解;
- 两者结合,效果远好于把全部任务扔给模型“一把梭”。
7.10 异步摘要与偏好提取
高并发下,摘要和偏好提取不一定要放在主链路同步完成。
@Component
public class ConversationPostProcessor {
private final SummaryService summaryService;
private final PreferenceExtractor preferenceExtractor;
@Async
@EventListener
public void handleConversationFinished(ConversationFinishedEvent event) {
summaryService.refreshIfNecessary(event.sessionId());
preferenceExtractor.extractAndMerge(event.userId(), event.userMessage(), event.assistantReply());
}
}
这样可以显著缩短用户主请求时延。
7.11 接口层
@RestController
@RequestMapping("/api/travel-agent")
public class TravelAgentController {
private final TravelAgentService travelAgentService;
public TravelAgentController(TravelAgentService travelAgentService) {
this.travelAgentService = travelAgentService;
}
@PostMapping("/chat")
public ChatReply chat(@Valid @RequestBody ChatRequestCommand command) {
return travelAgentService.chat(command);
}
}
如果系统面向 App 或 Web 前端,建议同时提供:
- 同步接口:返回即时答复;
- SSE / WebSocket 接口:返回流式内容;
- 异步任务查询接口:返回复杂规划状态。
8. 高并发与可扩展设计
这一部分是很多文章最欠缺、但最接近真实生产的问题。
8.1 应用实例必须无状态
如果会话状态放在应用内存中,会导致:
- 多实例之间状态不一致;
- 扩容后会话漂移;
- 重启丢失上下文;
- 无法做弹性调度。
因此,应用层只负责计算和编排,状态统一放在外部存储:
- 会话消息放
Redis;
- 用户画像放 MySQL /
Redis;
- 大对象结果放对象存储或数据库。
8.2 会话隔离与并发安全
同一用户可能同时发起多个会话,也可能在极短时间内连续点击“发送”。
需要处理以下问题:
- 同一个
sessionId 的并发写入顺序;
- 重复请求幂等;
- 流式输出与最终结果回写的一致性。
建议方案:
- 每个请求带
sessionId + requestId;
Redis 中对 requestId 做短期幂等校验;
- 同一
sessionId 可使用轻量分布式锁或顺序队列;
- 写消息时统一走 MemoryRepository,避免多处并发写。
8.3 Token 成本治理
高并发场景下,成本问题不是“优化项”,而是架构问题。
推荐组合策略:
- 只保留最近 N 轮消息;
- 超过阈值做摘要压缩;
- 结构化字段单独存储,不重复塞入 Prompt;
- 工具结果只保留关键字段;
- 使用便宜模型处理摘要、偏好提取等轻任务;
- 使用高质量模型处理最终规划解释。
8.4 缓存策略
以下数据适合缓存:
- 城市天气短时查询;
- 热门景点列表;
- 城市基础攻略;
- 标准交通耗时区间;
- 酒店区域画像。
但以下数据不应长期缓存:
- 强实时价格;
- 限量库存;
- 临时封路;
- 节假日动态开放状态。
缓存策略要遵循“能缓存的不重复算,不能缓存的必须设置超时”。
8.5 异步化拆分
主链路应控制在“用户可接受响应时间”之内,一般建议:
- 首轮识别与追问:1-3 秒;
- 简单规划:2-5 秒;
- 复杂多日深度规划:走异步任务。
适合异步化的任务包括:
- 多候选路线生成;
- 多城市联合规划;
- 酒店与景点组合评分;
- 大规模行程重排;
- 个性化攻略报告生成。
8.6 限流与降级
限流粒度建议至少包括:
降级路径建议如下:
- 天气服务不可用:使用保守方案,减少户外安排;
- 地图服务不可用:不输出精确通勤时间,只给区域级建议;
- 景点服务不可用:使用缓存或静态推荐;
- LLM 慢响应:切换到降级模型或简版模板回复。
9. 真实业务案例:三天杭州慢节奏行程
9.1 用户输入过程
用户第一轮:
五一想带父母去杭州玩三天,预算 3500,两个人,不想太赶。
系统从中提取:
- 目的地:杭州;
- 天数:3 天;
- 同行人群:父母,存在老人;
- 预算:3500;
- 偏好:慢节奏。
缺失字段:
- 出行日期;
- 酒店区域偏好;
- 饮食禁忌;
- 到达方式。
此时 Agent 不应该直接规划,而应该追问关键字段。
用户第二轮:
5 月 2 号出发,高铁过去,酒店最好住西湖附近,我爸不能吃辣。
此时长期画像中可写入:
withElderly = true
preferSlowPace = true
preferredTransport = "HIGH_SPEED_RAIL"
preferredHotelArea = "西湖附近"
avoidSpicyFood = true
9.2 规划逻辑
系统随后调用:
- 天气工具:判断 5 月 2 日至 5 月 4 日天气;
- 地图工具:估算西湖、灵隐寺、河坊街等区域转场成本;
- POI 工具:获取景点开放时间与标签;
- 规则引擎:避免高强度、长距离折返。
9.3 输出结果
最终输出应同时包含两部分。
自然语言:
已按“老人同行、慢节奏、西湖附近住宿、不吃辣”的条件为你生成三天方案。整体安排以西湖核心区为主,减少跨区折返;若第二天下雨,已预留中国丝绸博物馆等室内替代方案。
结构化结果:
{
"destination": "杭州",
"days": 3,
"degraded": false,
"dayPlans": [
{
"day": 1,
"activities": [
{"time": "09:30", "name": "抵达杭州东站后前往酒店办理入住"},
{"time": "14:00", "name": "西湖湖滨步行与休闲游船"},
{"time": "18:00", "name": "清淡杭帮菜晚餐"}
]
}
]
}
这样的输出才真正具备业务承接能力。
10. 稳定性治理
10.1 超时控制
对每个外部依赖都要设置清晰超时,不能使用默认值。
建议:
- LLM 调用:3-10 秒,视业务而定;
- 天气服务:300-800 ms;
- 地图服务:500-1500 ms;
- POI 服务:500-1000 ms;
Redis 访问:几十毫秒到百毫秒级。
10.2 熔断与舱壁隔离
核心思想是“局部失败不要拖垮整体”。
常见实践:
- 不同 Tool 使用独立线程池;
- 不同 Tool 使用独立熔断器;
- Tool 超时后返回降级结果而非传播异常;
- LLM 与业务数据库连接池隔离。
10.3 幂等设计
用户重复点击、网络重试、客户端超时重发都很常见。
需要做到:
- 同一个
requestId 只处理一次;
- 相同请求重复到达时返回已有结果;
- 异步事件消费支持去重。
10.4 灰度发布
Agent 系统上线后,Prompt、模型、摘要策略会频繁调整,因此灰度能力很重要。
建议可灰度的维度:
- 不同系统 Prompt;
- 不同模型;
- 不同摘要阈值;
- 不同 Tool 组合;
- 不同结构化输出模板。
11. 可观测性建设
如果线上出现“为什么这个用户今天回答变慢了”“为什么某些规划明显不合理”,没有可观测性基本无解。
11.1 必备指标
建议至少采集以下指标:
travel_agent_chat_latency_ms
travel_agent_llm_latency_ms
travel_agent_tool_latency_ms
travel_agent_prompt_tokens
travel_agent_completion_tokens
travel_agent_degrade_count
travel_agent_memory_hit_ratio
travel_agent_summary_refresh_count
11.2 Trace 设计
每个请求建议携带:
traceId
sessionId
userId
requestId
这样才能把如下链路串起来:
API 请求 -> Agent 编排 -> Redis 读取 -> Tool 调用 -> LLM 调用 -> 结果回写 -> 异步摘要
11.3 日志设计
注意不要把完整隐私数据直接明文打印到日志中。
建议日志字段包括:
- traceId;
- sessionId;
- toolName;
- costMs;
- degraded;
- tokenCount;
- errorCode。
用户消息内容应根据合规要求脱敏或采样记录。
12. 容器化与部署建议
12.1 Docker 化原则
镜像构建建议:
- 使用多阶段构建;
- 运行时镜像尽量精简;
- 配置通过环境变量注入;
- 健康检查显式配置;
- 禁止把密钥写死到镜像中。
12.2 Kubernetes 部署建议
在 K8s 中,建议至少配置:
readinessProbe 与 livenessProbe;
requests/limits;
- HPA 自动扩缩容;
- PodDisruptionBudget;
- ConfigMap 与 Secret 分离;
- 节点亲和性和跨可用区部署。
示例:
apiVersion: apps/v1
kind: Deployment
metadata:
name: travel-agent-service
spec:
replicas: 3
selector:
matchLabels:
app: travel-agent-service
template:
metadata:
labels:
app: travel-agent-service
spec:
containers:
- name: app
image: travel-agent-service:1.0.0
ports:
- containerPort: 8080
env:
- name: OPENAI_API_KEY
valueFrom:
secretKeyRef:
name: llm-secret
key: api-key
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
resources:
requests:
cpu: "500m"
memory: "512Mi"
limits:
cpu: "2"
memory: "2Gi"
13. 常见问题与解决策略
13.1 聊天记忆越来越大怎么办
解决思路:
- 只保留最近窗口;
- 周期性摘要;
- 长期偏好结构化存储;
- 无效消息清理;
- Prompt 中只注入当前任务相关内容。
13.2 规划结果前后不一致怎么办
根因通常有三个:
- 记忆污染;
- 外部工具返回波动;
- Prompt 缺乏约束。
解决方案:
- 加强结构化参数抽取;
- 对关键工具结果做缓存和标准化;
- 将业务规则从 Prompt 中下沉到规则引擎。
13.3 高峰时段响应过慢怎么办
处理优先级建议如下:
- 缩短 Prompt;
- 增加摘要压缩;
- 下沉工具结果缓存;
- 复杂规划改异步;
- 对不同任务使用分级模型;
- 扩容 Worker 与应用实例。
13.4 如何避免模型“编造”
不要只依赖“请不要胡说八道”这种提示词。
更有效的方法是:
- 对关键字段使用结构化约束;
- 对外部事实强制走 Tool;
- 对不确定字段要求显式标记;
- 对最终输出做规则校验。
14. 演进路线
一个成熟的智能行程规划 Agent 往往不是一次性完成,而是分阶段演进。
第一阶段:可用
- 单会话聊天;
Redis 短期记忆;
- 基础天气与 POI 工具;
- 简单行程输出。
第二阶段:可运营
- 用户画像沉淀;
- 摘要压缩;
- 指标与日志完善;
- 熔断、限流、降级接入;
- K8s 部署。
第三阶段:可增长
- 多模型路由;
- 成本优化;
- A/B Prompt 实验;
- 多城市联动规划;
- 结果反馈闭环。
第四阶段:可平台化
- Tool 平台统一管理;
- Agent 模板配置化;
- 多租户隔离;
- 行业知识库接入;
- 运营后台可视化调试。
15. 总结
真正有业务价值的 Spring AI 应用,不是“接入一个大模型接口”,而是围绕以下几个问题做完整工程设计:
- 如何管理上下文,而不是无限堆历史消息;
- 如何把长期偏好从对话中抽取出来;
- 如何让模型负责理解与表达,而让规则和工具负责约束求解;
- 如何在高并发下保证性能、稳定性和成本可控;
- 如何让系统具备可观测、可灰度、可演进能力。
对于“智能行程规划 Agent”这类场景,推荐的核心方法论可以概括为一句话:
用 Spring AI 做编排,用 Redis 做会话记忆,用结构化画像承接长期偏好,用 Tool + 规则引擎约束业务事实,用异步化和治理能力支撑生产流量。
如果只是做 Demo,系统也许只需要一个 Controller 和一次模型调用;但如果目标是上线到真实业务场景,就必须把它当作一个完整的分布式智能系统来设计。
这,才是 Spring AI 在企业应用中的真正打开方式。 如果你想深入探讨更多 Spring Boot 与 Agent 架构的实践经验,欢迎在 云栈社区 的技术论坛中与我们交流。