找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

4979

积分

0

好友

688

主题
发表于 昨天 04:30 | 查看: 69| 回复: 0

在“AI + 业务系统”真正落地的过程中,很多团队会发现一个典型问题:只接一个大模型接口,很容易做出 Demo;但要做成一个能承载真实流量、具备上下文记忆、可观测、可扩展、可治理的 Agent 系统,难度会陡增。

以“智能行程规划”为例,用户不会只问一句“帮我规划去北京的旅行”。真实场景中的对话往往是连续、多轮、动态变化的:

  • “我五一想去杭州玩三天,预算 3000。”
  • “不要太早起,酒店离西湖近一点。”
  • “第二天下雨的话,把户外项目换成室内。”
  • “另外我不吃辣,同行还有一位老人。”

这类需求天然依赖上下文、用户偏好、实时外部数据和多步骤决策,因此非常适合 Agent 化建模。但如果没有工程化设计,系统很快会暴露出几个问题:

  • 对话上下文无限增长,Prompt 成本失控;
  • 多实例部署后,记忆状态不一致;
  • 外部地图、天气、景点服务抖动导致整体雪崩;
  • 高并发下 Redis、LLM、下游接口成为瓶颈;
  • 缺少可观测性,线上问题无法定位。

本文不再停留在“能跑起来”的示例层面,而是从架构、原理、工程实现和生产优化四个维度,完整讲解如何基于 Spring AI 构建一个可用于真实业务的智能行程规划 Agent。

文章目标如下:

  1. 深入解释 Spring AI 中聊天记忆、Prompt 组装、Agent 编排的核心原理;
  2. 给出适用于高并发场景的系统架构和工程治理方案;
  3. 提供接近生产可用的代码骨架,而不是仅供演示的伪代码;
  4. 用真实业务案例说明如何把“对话式 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 不是单次“问答”,而是一条有状态的执行链路:

  1. 接收用户输入;
  2. 识别意图;
  3. 读取会话记忆和用户画像;
  4. 判断信息是否充分;
  5. 必要时追问补齐参数;
  6. 调用天气、地图、POI、预算等工具;
  7. 生成候选规划;
  8. 输出自然语言答复和结构化结果;
  9. 回写消息、摘要和画像。

这意味着系统设计不能只围绕 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 的并发写入顺序;
  • 重复请求幂等;
  • 流式输出与最终结果回写的一致性。

建议方案:

  1. 每个请求带 sessionId + requestId
  2. Redis 中对 requestId 做短期幂等校验;
  3. 同一 sessionId 可使用轻量分布式锁或顺序队列;
  4. 写消息时统一走 MemoryRepository,避免多处并发写。

8.3 Token 成本治理

高并发场景下,成本问题不是“优化项”,而是架构问题。

推荐组合策略:

  • 只保留最近 N 轮消息;
  • 超过阈值做摘要压缩;
  • 结构化字段单独存储,不重复塞入 Prompt;
  • 工具结果只保留关键字段;
  • 使用便宜模型处理摘要、偏好提取等轻任务;
  • 使用高质量模型处理最终规划解释。

8.4 缓存策略

以下数据适合缓存:

  • 城市天气短时查询;
  • 热门景点列表;
  • 城市基础攻略;
  • 标准交通耗时区间;
  • 酒店区域画像。

但以下数据不应长期缓存:

  • 强实时价格;
  • 限量库存;
  • 临时封路;
  • 节假日动态开放状态。

缓存策略要遵循“能缓存的不重复算,不能缓存的必须设置超时”。

8.5 异步化拆分

主链路应控制在“用户可接受响应时间”之内,一般建议:

  • 首轮识别与追问:1-3 秒;
  • 简单规划:2-5 秒;
  • 复杂多日深度规划:走异步任务。

适合异步化的任务包括:

  • 多候选路线生成;
  • 多城市联合规划;
  • 酒店与景点组合评分;
  • 大规模行程重排;
  • 个性化攻略报告生成。

8.6 限流与降级

限流粒度建议至少包括:

  • 用户级;
  • 租户级;
  • 模型级;
  • 工具级。

降级路径建议如下:

  1. 天气服务不可用:使用保守方案,减少户外安排;
  2. 地图服务不可用:不输出精确通勤时间,只给区域级建议;
  3. 景点服务不可用:使用缓存或静态推荐;
  4. 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 中,建议至少配置:

  • readinessProbelivenessProbe
  • 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 高峰时段响应过慢怎么办

处理优先级建议如下:

  1. 缩短 Prompt;
  2. 增加摘要压缩;
  3. 下沉工具结果缓存;
  4. 复杂规划改异步;
  5. 对不同任务使用分级模型;
  6. 扩容 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 BootAgent 架构的实践经验,欢迎在 云栈社区 的技术论坛中与我们交流。




上一篇:Spring AI Alibaba 多智能体:构建生产级高并发方案策划系统
下一篇:千万 QPS 系统架构演进:构建从连接数到反馈式的精细化缓存限流体系
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-4-7 17:00 , Processed in 0.608642 second(s), 39 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表