【阶段一】基于 Spring AI 搭起 LingNova 的第一个可用 Agent

Agent开发:把不稳定的模型能力治理成可控的后端能力
前言:这是 LingNova 企业级 Agent 项目的第一篇开发记录。最近Agent和Harness可以说是风头正盛,如何把一个“黑盒”包装为企业级可控的AI应用,技术路线越来越清晰。看了很多技术介绍,总觉得“纸上得来终觉浅”,网上也没有找到很合适的开源项目,于是想自己动手搭建一个企业级HarnessAgent项目—LingNova(灵核),边动手边学习Agent相关技术栈,这个项目的目标是做一个能做产品选型、知识问答、故障排查和资讯聚合的机器人行业AI智能管家。

一、阶段开发计划

阶段一:基础框架搭建
目标:接入 Spring AI Alibaba 与 Deepseek,实现第一个能调用工具的 Agent。
阶段二:Harness 工程化改造
目标:引入 AgentScope Java Harness,实现有状态、可恢复、有记忆的 Agent;解决上下文爆炸问题。
阶段三:多 Agent + RAG + 工具生态
目标:实现复杂任务自动拆解,引入 RAG、多 Agent 协作、MCP 工具协议。
阶段四:企业级治理
目标:让 Agent 具备生产级能力:评估体系、权限隔离、安全护栏、可观测性。

二、技术栈介绍

由于我本身是做Java后端开发,所以这个项目注重探索如何在传统Java后端项目中构建Agent项目,因此使用Spring AI相关的技术框架开始搭建,整体的技术栈如下

层级 技术
后端框架 Spring Boot 3.2+ + Spring AI Alibaba 1.1.2.x
Agent 运行时 AgentScope Java Harness 2.x
大模型 DashScope(通义千问)、DeepSeek、OpenAI 兼容接口
向量数据库 PGVector / Milvus
缓存/会话 Redis
可观测性 OpenTelemetry + Langfuse / LangSmith
部署 Docker + Kubernetes

三、Agent VS chat

可能是网页版AI对话用的太多形成惯性思维,刚开始接触大模型时,很容易把 Agent 理解成:

前端传一句话,后端把这句话交给模型,再把结果返回,要加功能就优化提示词。

虽然与Agent的交流方式基本都是通过聊天对话,但是与传统对话方式不同的是,Agent背后有更多的业务调用逻辑。

以机器人选型为例,用户问“预算 20 万,想做焊接产线,推荐什么机器人”,模型不应该凭训练数据编一个型号和价格;它应该先判断需要查平台自己的产品数据,再根据工具返回的结果组织答案。这里的“判断—调用—整合”才是 Agent 最小但关键的闭环。

所以阶段一我给自己定的目标不是堆功能,而是先打通下面这条链路:

用户请求
  -> ChatController
  -> ChatService / ChatClient
  -> Advisor 链(日志、敏感词、指标)
  -> 大模型判断是否调用 Tool
  -> RobotProductTool 查询机器人服务
  -> 大模型整合业务数据
  -> 返回同步响应或 SSE 流

这套结构后面可以继续承接 Harness 的 Session、Workspace、长期记忆和多 Agent,而不会推翻已经写好的业务入口。

四、阶段一完成了什么

阶段一实际完成的能力包括:

  • 基于 Spring Boot 3.4、Java 17 和 Spring AI 1.0 搭建 AI 服务;
  • 通过 OpenAI 兼容协议统一接入 DeepSeek、GLM 等模型,避免业务层绑定单一厂商;
  • 提供同步对话、SSE 流式对话两个接口;
  • 将机器人产品查询封装成 Spring AI Tool,让模型按需调用真实业务 API;
  • 使用 Redis 保存最近 20 条消息,实现多轮上下文和服务重启后的恢复;
  • 通过 Advisor 完成调用日志、敏感词拦截、Token 和延迟指标采集;
  • 用 Actuator 暴露健康检查和指标,为后续 Prometheus/Grafana 做准备。

在第一阶段只是先做到AI能够调用deepseek进行对话、大模型可以正常判断并调用tool、调用日志记录、敏感词拦截、指标采集、简单的记忆功能,把架子搭起来,后面再慢慢进化。

五、核心知识梳理

在这里我只梳理代码实践过程中关键一些的知识概念,不会特别详细复述完整概念,更详细全面的知识可以看B站的一些系统课,因为这是个实践型项目。

1.Spring AI中 ChatModelChatClient 和 Agent 分别负责什么

我一开始也容易把它们混为一谈,后来按职责拆开看就清楚了:

组件 解决的问题 在项目中的位置
ChatModel 如何与具体模型供应商通信 AiModelConfig
ChatClient 如何组织 Prompt、Advisor、Tool 和调用方式 ChatService
Tool Agent 可以操作哪些外部能力 RobotProductTool
Advisor 在模型调用前后插入横切逻辑,类似AOP 日志、安全、指标
Agent/Harness 长任务、状态、工作区、记忆压缩和任务循环 下一阶段引入

阶段一并没有急着把所有事情都叫作 Harness Agent。现在这层更像一个“具备工具调用能力的 Chat Agent”:先验证模型、业务工具和观测链路,再在第二阶段把它升级为带 Workspace 和可恢复 Session 的 Harness Agent。

2. Function Calling 的价值不在“会调方法”,而在边界清晰

Spring AI 通过 @Tool@ToolParam 把 Java 方法描述成模型可调用的工具。模型看到工具描述后,决定是否调用、调用哪个方法、参数是什么;真正的数据库或微服务访问仍然在后端代码里完成。(6.2有例子)

这样做有两个好处:

  1. 模型负责语义理解和编排,Java 负责权限、超时、异常处理与数据访问;
  2. 工具返回结构化 DTO,而不是一大段拼接字符串,模型更容易引用,后续也更容易审计。

需要特别注意:Tool 的描述和参数描述就是给模型看的“接口文档”。描述写得含糊,模型就更容易选错工具或传错参数。它不是普通注释,应该按 API 契约认真维护。

3. Advisor 是把“非功能需求”从业务代码里拿出去

真实项目里,对话服务还会不断长出日志、安全、限流、埋点、审计等需求。如果都塞进 ChatService,很快就会变成难以维护的流程代码。

Spring AI 的 Advisor 可以在请求前和响应后执行逻辑,适合处理这些横切关注点。当前项目的默认顺序是:

ChatLoggingAdvisor -> SensitiveWordAdvisor -> AgentMetricsAdvisor -> 模型调用

其中敏感词拦截在调用模型前短路请求;指标 Advisor 在响应后读取 Usage,把 Token 和耗时交给 Micrometer。这个顺序不是绝对标准,但原则很简单:安全校验要尽早,埋点要能拿到最终结果,日志要避免把密钥或完整隐私数据写出去。

4. 多轮对话要先控制窗口,再谈长期记忆

大模型并不会天然“记住”用户。所谓多轮对话,本质是下一次调用时把历史消息一起带上。历史无限累积会导致 Token 成本和延迟线性上涨,所以阶段一先使用窗口记忆:只保留最近 20 条消息,并放到 Redis。

短期记忆:最近 N 条消息,保证当前话题连续
长期记忆:用户偏好、事实摘要,下一阶段再用 Harness + MEMORY.md 管理

这是一个有意的分层。不要一开始就把全部聊天记录塞进 Prompt;先让窗口记忆稳定运行,后面再引入压缩和长期记忆,问题会清晰很多。

六、关键代码实操

1. 用 OpenAI 兼容协议配置可切换模型

模型配置统一放在 application.yml 中。真实的 API Key 不应该提交到 Git,这里只保留环境变量写法。

lingnova:
  ai:
    models:
      chat:
        provider: ${AI_CHAT_PROVIDER:deepseek}
        providers:
          deepseek:
            base-url: https://api.deepseek.com
            api-key: ${DEEPSEEK_API_KEY}
            model: ${AI_CHAT_MODEL:deepseek-chat}
          glm:
            base-url: https://open.bigmodel.cn/api/paas/v4
            api-key: ${GLM_API_KEY}
            model: glm-4

在配置类中,根据当前 provider 构建 OpenAiApiOpenAiChatModel。这里的关键点是把连接超时和读取超时显式配置上:模型服务慢或者不可用时,不能让应用线程一直卡住。

@Bean
public ChatModel chatModel(MultiModelProperties props,
                           AiContentProperties aiProps) {
    var config = props.getChat().resolveProvider();

    RestClient.Builder restClient = buildRestClientBuilder(
            aiProps.getChatConnectTimeoutSeconds(),
            aiProps.getChatReadTimeoutSeconds());

    OpenAiApi api = OpenAiApi.builder()
            .baseUrl(config.getBaseUrl())
            .apiKey(config.getApiKey())
            .restClientBuilder(restClient)
            .build();

    return OpenAiChatModel.builder()
            .openAiApi(api)
            .defaultOptions(OpenAiChatOptions.builder()
                    .model(config.getModel())
                    .build())
            .build();
}

2. 把业务 API 包装成 Tool

产品查询不是让模型直接连数据库,而是由 RobotProductTool 调用 lingnova-robot-service 的 REST API。下面是按关键字查询产品的精简实现:

@Tool(description = "按关键字搜索机器人产品。当用户提到具体产品、品牌、型号或应用场景时调用,返回产品简要信息。")
public List<RobotProductSummary> searchRobots(
        @ToolParam(description = "搜索关键字,例如:焊接机器人、人形机器人、优必选") String keyword,
        @ToolParam(description = "最大返回数量,默认 5,最大 20") Integer limit) {

    int size = limit != null ? Math.min(limit, 20) : 5;
    try {
        Result<List<RobotProductSummary>> result = restClient.get()
                .uri(uriBuilder -> uriBuilder
                        .path("/api/robots/search")
                        .queryParam("keyword", keyword)
                        .build())
                .retrieve()
                .body(new ParameterizedTypeReference<>() {});
        return unwrap(result, size);
    } catch (Exception e) {
        log.warn("搜索机器人产品失败: keyword={}, error={}", keyword, e.getMessage());
        return List.of();
    }
}

这里刻意做了三件小事:限制 limit 上限、返回结构化对象、异常时返回空列表并记录日志。它们看起来普通,但直接决定了模型上下文是否可控、调用失败时是否会拖垮一次对话。

3. 在 ChatClient 中注册系统提示词、工具和记忆

ChatService 启动时初始化 ChatClient。系统提示词负责定义边界,defaultTools 注册业务工具,记忆 Advisor 在每次调用时按会话 ID 注入上下文。

@PostConstruct
private void init() {
    chatMemoryAdvisor = MessageChatMemoryAdvisor.builder(chatMemory).build();
    chatClient = chatClientBuilder
            .defaultSystem(SYSTEM_PROMPT)
            .defaultTools(robotProductTool, robotQueryTool, knowledgeQueryTool)
            .build();
}

public String chat(String message, String sessionId) {
    return chatClient.prompt()
            .user(message)
            .advisors(chatMemoryAdvisor)
            .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, sessionId))
            .call()
            .content();
}

sessionId 是多轮对话的关键。前端首次不传时由服务端生成,后续每次带回即可命中同一个 Redis 会话;如果没有它,表面上虽然是聊天,实际上每一轮都是独立请求。

对应的 Redis 窗口记忆配置如下:

@Bean
public ChatMemory chatMemory(StringRedisTemplate redisTemplate) {
    return MessageWindowChatMemory.builder()
            .chatMemoryRepository(new RedisChatMemoryRepository(redisTemplate))
            .maxMessages(20)
            .build();
}

4. 用 Advisor 记录延迟和 Token

下面的代码展示了指标 Advisor 最核心的部分:请求前记录时间,响应后读取模型返回的 Token 用量,并写入 Micrometer。

@Override
public ChatClientRequest before(ChatClientRequest request, AdvisorChain chain) {
    request.context().put("agent_metrics_start_time", System.nanoTime());
    return request;
}

@Override
public ChatClientResponse after(ChatClientResponse response, AdvisorChain chain) {
    long durationMs = TimeUnit.NANOSECONDS.toMillis(
            System.nanoTime() - (Long) response.context().get("agent_metrics_start_time"));

    Usage usage = response.chatResponse().getMetadata().getUsage();
    meterRegistry.counter("lingnova.ai.chat.requests", tags).increment();
    meterRegistry.timer("lingnova.ai.chat.latency", tags)
            .record(durationMs, TimeUnit.MILLISECONDS);
    meterRegistry.summary("lingnova.ai.chat.tokens.total", tags)
            .record(usage.getTotalTokens());
    return response;
}

当前已经采集了请求次数、端到端耗时、输入 Token、输出 Token、总 Token,并使用 modelprovider 作为标签。后面接入 Prometheus 后,就可以回答一些以前很难回答的问题:哪个模型慢、哪类请求贵、工具调用是否真的改善了回答质量。

5. 提供同步和流式接口

同步接口适合后台调用或快速验证;面向用户的聊天页面更适合 SSE 流式输出。

@PostMapping("/chat")
public ResponseEntity<Map<String, Object>> chat(
        @RequestParam String message,
        @RequestParam(required = false) String sessionId) {
    String id = (sessionId == null || sessionId.isBlank())
            ? UUID.randomUUID().toString() : sessionId;
    return ResponseEntity.ok(Map.of(
            "sessionId", id,
            "reply", chatService.chat(message, id)
    ));
}

@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> chatStream(@RequestParam String message,
                               @RequestParam(required = false) String sessionId) {
    String id = (sessionId == null || sessionId.isBlank())
            ? UUID.randomUUID().toString() : sessionId;
    return chatService.chatStream(message, id);
}

七、如何本地验证

同步接口验证基础对话:

curl -X POST "http://localhost:9005/api/ai/chat/chat?message=推荐一款适合焊接产线的机器人"

响应会返回服务端生成的 sessionId。把它用于下一轮请求,验证上下文是否连贯:

curl -X POST "http://localhost:9005/api/ai/chat/chat?sessionId=上一步返回的sessionId&message=预算控制在20万以内"

流式接口可以直接用浏览器或前端 EventSource 访问:

GET http://localhost:9005/api/ai/chat/stream?message=介绍一下协作机器人&sessionId=your-session-id

最后检查基础可观测性:

GET http://localhost:9005/actuator/health
GET http://localhost:9005/actuator/metrics/lingnova.ai.chat.latency
GET http://localhost:9005/actuator/metrics/lingnova.ai.chat.tokens.total

八、阶段一踩到的坑和我的理解

1. 工具异常不能直接把整个对话打断

下游机器人服务偶发超时是正常情况。阶段一先在 Tool 内捕获异常,返回空结果并记录告警日志;下一阶段会继续补上统一的超时、重试、熔断和幂等能力。比起“工具报错就 500”,让模型知道“没有查到数据”通常更接近用户需要的体验。

2. “支持多轮”不等于无限保存聊天记录

Redis 解决了会话持久化,不解决上下文膨胀。20 条窗口是当前阶段的成本控制策略,不是最终答案。后续 Harness 的 Compaction 和长期记忆,才是让长对话可持续的关键。

3. 先把可观测性放进第一天的代码里

传统接口慢了还可以看 SQL、线程池;Agent 慢了则可能是模型、Prompt、上下文、工具服务中的任意一环。没有延迟和 Token 指标,后面只能凭感觉优化。这个项目从第一阶段就埋点,是为了后面每个优化都有数据依据。

九、下一步:从 Chat Agent 走向 Harness Agent

阶段一验证的是“模型能否进入真实业务系统”。下一阶段才是我最想深入的 Harness:为每个用户和会话建立可恢复的 Workspace,引入 AGENTS.mdMEMORY.md、上下文压缩和 Token 预算管理。

我现在的理解是:Agent 的难点不在于调用一次大模型,而在于让它在多轮、长任务、失败和重启之后依然行为稳定、成本可控、过程可追踪。阶段一先把地基铺好,后面的Harness、RAG 和多 Agent 才有地方落脚。


项目代码暂时未开源,只用git私库管理,最主要的原因是目前的框架还在初步阶段,没有很大的开源意义,把这篇文章扔给AI也能写出差不多的;其次是配套的前端页面还没有时间整理,观感较差。等后续有进步了再考虑开源吧。

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐