从0到1开发一个新闻智能体

基于大模型的智能体实战,覆盖新闻采集、摘要生成、语义检索、个性化推荐与语音播报全流程。

一、项目背景与目标

传统新闻聚合平台主要依赖编辑人工筛选和简单规则推荐,存在以下痛点:

  • 信息过载:每天产生的新闻量巨大,用户难以快速获取感兴趣的内容
  • 摘要质量差:基于模板或抽取式摘要,缺乏对语义的理解
  • 推荐不精准:基于标签的关键词匹配,无法理解用户真实意图
  • 交互形式单一:仅支持文字阅读,缺乏语音播报等沉浸式体验

本项目旨在构建一个新闻智能体,利用大语言模型(LLM)具备以下能力:

能力 说明
智能摘要 对新闻进行多维度、可控长度的语义摘要
语义检索 基于向量数据库的语义级别新闻搜索
个性化推荐 结合用户画像和新闻语义的精准推荐
情感分析 对新闻内容和用户评论进行细粒度情感判断
语音播报 将新闻摘要转为自然流畅的语音
多语言翻译 支持中英日等多语言新闻互译

二、整体架构

┌─────────────────────────────────────────────────────────────┐
│                        用户交互层                            │
│   Web App / 小程序 / 语音助手                                │
└──────────────┬──────────────────────────────────────────────┘
               │
┌──────────────▼──────────────────────────────────────────────┐
│                      API 网关层                              │
│          Spring Cloud Gateway / Nginx                        │
└──────────────┬──────────────────────────────────────────────┘
               │
┌──────────────▼──────────────────────────────────────────────┐
│                     业务服务层                                │
│  ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐       │
│  │新闻采集   │ │智能摘要   │ │语义检索   │ │个性化推荐 │       │
│  │ Service  │ │ Service  │ │ Service  │ │ Service  │       │
│  └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘       │
│       │            │            │            │               │
│  ┌────▼────────────▼────────────▼────────────▼─────┐       │
│  │              Agent 编排引擎                       │       │
│  │       (Spring AI Alibaba / LangChain4j)           │       │
│  └──────────────────┬───────────────────────────────┘       │
└─────────────────────┼───────────────────────────────────────┘
                      │
┌─────────────────────▼───────────────────────────────────────┐
│                     AI 能力层                                │
│  ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐       │
│  │ LLM 大模型│ │  Embedding│ │  ASR 语音 │ │  TTS 语音 │       │
│  │ 通义千问   │ │   向量化  │ │   识别   │ │   合成   │       │
│  └──────────┘ └──────────┘ └──────────┘ └──────────┘       │
└─────────────────────┬───────────────────────────────────────┘
                      │
┌─────────────────────▼───────────────────────────────────────┐
│                     数据存储层                                │
│  ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐       │
│  │  MySQL   │ │Elasticsearch│ │  Redis   │ │  OSS     │       │
│  │ 业务数据  │ │ 向量+全文检索 │ │   缓存   │ │ 文件存储  │       │
│  └──────────┘ └──────────┘ └──────────┘ └──────────┘       │
└─────────────────────────────────────────────────────────────┘

三、技术选型

层级 技术 选型理由
开发框架 Spring Boot 3.x Java 生态成熟,团队技术栈统一
AI 集成 Spring AI Alibaba 阿里云官方 AI 框架,原生支持通义千问
大模型 通义千问(Qwen) 中文能力强,支持 Function Calling
向量数据库 Elasticsearch 8.x 原生支持 BM25 + kNN 混合检索
消息队列 Kafka 新闻采集管道的异步解耦
定时任务 XXL-JOB 采集任务的分布式调度
缓存 Redis 热点新闻缓存、用户会话管理
对象存储 阿里云 OSS 音频文件、图片等资源存储
数据库 MySQL 业务元数据存储

四、数据库设计

4.1 新闻源表 news_source

记录配置的新闻数据来源。

CREATE TABLE `news_source` (
    `id` BIGINT NOT NULL AUTO_INCREMENT,
    `name` VARCHAR(128) NOT NULL COMMENT '新闻源名称,如:新华网、澎湃新闻',
    `source_type` TINYINT NOT NULL COMMENT '来源类型:1-RSS, 2-API, 3-网页爬取',
    `url` VARCHAR(512) NOT NULL COMMENT 'RSS地址或API接口或网站首页URL',
    `category` VARCHAR(64) DEFAULT NULL COMMENT '新闻分类:时政、财经、科技、体育等',
    `language` VARCHAR(16) DEFAULT 'zh' COMMENT '语言:zh/en/ja',
    `crawl_interval` INT DEFAULT 30 COMMENT '采集间隔(分钟)',
    `enabled` TINYINT DEFAULT 1 COMMENT '是否启用',
    `status` TINYINT DEFAULT 0 COMMENT '状态:0-正常, 1-异常',
    `last_crawl_time` DATETIME DEFAULT NULL COMMENT '最近一次采集时间',
    `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
    `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    `del_flag` TINYINT DEFAULT 0,
    PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='新闻源配置表';

4.2 新闻文章表 news_article

存储采集到的新闻元数据。

CREATE TABLE `news_article` (
    `id` BIGINT NOT NULL AUTO_INCREMENT,
    `source_id` BIGINT NOT NULL COMMENT '新闻源ID',
    `title` VARCHAR(512) NOT NULL COMMENT '标题',
    `author` VARCHAR(128) DEFAULT NULL COMMENT '作者',
    `content` LONGTEXT COMMENT '正文内容',
    `content_hash` VARCHAR(64) DEFAULT NULL COMMENT '内容摘要Hash,用于去重',
    `cover_image_url` VARCHAR(512) DEFAULT NULL COMMENT '封面图URL',
    `publish_time` DATETIME DEFAULT NULL COMMENT '发布时间',
    `category` VARCHAR(64) DEFAULT NULL COMMENT '分类',
    `tags` VARCHAR(512) DEFAULT NULL COMMENT '标签,逗号分隔',
    `language` VARCHAR(16) DEFAULT 'zh',
    `sentiment` VARCHAR(32) DEFAULT NULL COMMENT '情感倾向:positive/negative/neutral',
    `summary` TEXT DEFAULT NULL COMMENT 'AI生成的摘要',
    `summary_en` TEXT DEFAULT NULL COMMENT '英文摘要',
    `audio_url` VARCHAR(512) DEFAULT NULL COMMENT '语音播报音频URL',
    `embedding_status` TINYINT DEFAULT 0 COMMENT '向量化状态:0-未处理, 1-已处理',
    `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
    `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    `del_flag` TINYINT DEFAULT 0,
    PRIMARY KEY (`id`),
    KEY `idx_source_publish` (`source_id`, `publish_time`),
    KEY `idx_category` (`category`),
    KEY `idx_content_hash` (`content_hash`),
    KEY `idx_publish_time` (`publish_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='新闻文章表';

4.3 用户画像表 user_profile

存储用户的兴趣偏好。

CREATE TABLE `user_profile` (
    `id` BIGINT NOT NULL AUTO_INCREMENT,
    `user_id` BIGINT NOT NULL COMMENT '用户ID',
    `preferred_categories` VARCHAR(256) DEFAULT NULL COMMENT '偏好分类,JSON数组',
    `preferred_tags` VARCHAR(512) DEFAULT NULL COMMENT '偏好标签,JSON数组',
    `language` VARCHAR(16) DEFAULT 'zh',
    `interaction_count` INT DEFAULT 0 COMMENT '总交互次数',
    `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户画像表';

4.4 用户交互记录表 user_interaction

记录用户与新闻的交互行为,用于推荐系统的特征工程。

CREATE TABLE `user_interaction` (
    `id` BIGINT NOT NULL AUTO_INCREMENT,
    `user_id` BIGINT NOT NULL,
    `article_id` BIGINT NOT NULL,
    `action` VARCHAR(32) NOT NULL COMMENT '行为类型:click/read/like/share/dislike',
    `duration` INT DEFAULT NULL COMMENT '阅读时长(秒)',
    `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (`id`),
    KEY `idx_user_action` (`user_id`, `action`),
    KEY `idx_article` (`article_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户交互记录表';

五、项目结构

news-agent-server/
├── pom.xml
├── src/main/java/com/example/newsagent/
│   ├── NewsAgentApplication.java
│   ├── config/
│   │   ├── DashScopeConfig.java            # 大模型客户端配置
│   │   ├── ElasticsearchConfig.java        # ES 向量检索配置
│   │   └── KafkaConfig.java                # 消息队列配置
│   ├── agent/
│   │   ├── NewsAgent.java                  # 智能体编排核心
│   │   ├── tool/
│   │   │   ├── NewsSearchTool.java         # 新闻搜索工具
│   │   │   ├── SummaryTool.java            # 摘要生成工具
│   │   │   ├── TranslateTool.java          # 翻译工具
│   │   │   └── SentimentTool.java          # 情感分析工具
│   │   └── prompt/
│   │       └── PromptTemplate.java         # Prompt 模板管理
│   ├── crawl/
│   │   ├── CrawlScheduler.java             # 采集调度器
│   │   ├── RssCrawler.java                 # RSS 采集器
│   │   ├── WebCrawler.java                 # 网页采集器
│   │   └── CrawlPipeline.java              # 采集后处理管道
│   ├── enrichment/
│   │   ├── ArticleEnrichmentService.java   # 文章丰富化(分类、实体抽取、情感)
│   │   ├── SummaryService.java             # 摘要生成
│   │   ├── EmbeddingService.java           # 文章向量化
│   │   └── TtsService.java                 # 文本转语音
│   ├── search/
│   │   ├── NewsSearchService.java          # 新闻搜索(混合检索)
│   │   └── RecommendService.java           # 个性化推荐
│   ├── domain/
│   │   ├── NewsArticle.java
│   │   ├── NewsSource.java
│   │   ├── UserProfile.java
│   │   └── UserInteraction.java
│   ├── dao/
│   │   ├── NewsArticleMapper.java
│   │   ├── NewsSourceMapper.java
│   │   └── UserProfileMapper.java
│   └── controller/
│       ├── NewsController.java             # 新闻查询接口
│       ├── ChatController.java             # 智能体对话接口
│       └── UserController.java             # 用户偏好管理接口
└── src/main/resources/
    ├── application.yml
    ├── mapper/
    │   ├── NewsArticleMapper.xml
    │   └── NewsSourceMapper.xml
    └── prompts/
        ├── summary.txt                     # 摘要生成 Prompt
        ├── classify.txt                    # 分类 Prompt
        └── sentiment.txt                   # 情感分析 Prompt

六、核心模块实现

6.1 大模型接入

引入 Spring AI Alibaba 依赖:

<dependency>
    <groupId>com.alibaba.cloud.ai</groupId>
    <artifactId>spring-ai-alibaba-starter</artifactId>
    <version>1.0.0-M5</version>
</dependency>

配置文件 application.yml

spring:
  ai:
    dashscope:
      # TODO: 替换为你的 DashScope API Key
      api-key: ${DASHSCOPE_API_KEY}
      chat:
        options:
          model: qwen-plus
          temperature: 0.7
      embedding:
        options:
          model: text-embedding-v3

说明:通义千问通过阿里云 DashScope 平台提供 API 服务,支持 OpenAI 兼容接口。申请 API Key 请前往 阿里云百炼控制台

主要模型选择:

  • qwen-turbo:速度快、成本低,适合分类、标签抽取等简单任务
  • qwen-plus:均衡型,适合摘要生成、翻译等中等复杂度任务
  • qwen-max:能力最强,适合复杂推理、多步骤任务编排

6.2 新闻采集模块

6.2.1 RSS 采集器
@Slf4j
@Component
public class RssCrawler {

    @Resource
    private NewsSourceMapper sourceMapper;

    public List<NewsArticle> crawl(NewsSource source) {
        List<NewsArticle> articles = new ArrayList<>();
        try {
            URL feedUrl = new URL(source.getUrl());
            SyndFeedInput input = new SyndFeedInput();
            SyndFeed feed = input.build(new XmlReader(feedUrl));

            for (SyndEntry entry : feed.getEntries()) {
                NewsArticle article = new NewsArticle();
                article.setSourceId(source.getId());
                article.setTitle(entry.getTitle());
                article.setAuthor(entry.getAuthor());
                article.setPublishTime(entry.getPublishedDate() != null
                        ? new Timestamp(entry.getPublishedDate().getTime()) : null);
                // 正文可能有 HTML,后续统一清洗
                String content = entry.getDescription() != null
                        ? entry.getDescription().getValue() : "";
                article.setContent(HtmlUtil.cleanHtmlTag(content));
                article.setContentHash(DigestUtil.md5Hex(article.getContent()));
                article.setLanguage(source.getLanguage());
                articles.add(article);
            }
            log.info("RSS采集完成,source:{},文章数:{}", source.getName(), articles.size());
        } catch (Exception e) {
            log.error("RSS采集失败,source:{}", source.getName(), e);
        }
        return articles;
    }
}
6.2.2 网页采集器
@Slf4j
@Component
public class WebCrawler {

    public List<NewsArticle> crawl(NewsSource source) {
        List<NewsArticle> articles = new ArrayList<>();
        try {
            // 使用 Jsoup 抓取静态页面
            Document doc = Jsoup.connect(source.getUrl())
                    .userAgent("Mozilla/5.0 NewsAgentBot/1.0")
                    .timeout(10000)
                    .get();

            // 根据不同新闻源配置 CSS 选择器提取内容
            Elements newsItems = doc.select(source.getCrawlRule());
            for (Element item : newsItems) {
                NewsArticle article = new NewsArticle();
                article.setTitle(item.select(".title").text());
                article.setContent(item.select(".content").text());
                article.setContentHash(DigestUtil.md5Hex(article.getContent()));
                // ... 其他字段提取
                articles.add(article);
            }
        } catch (Exception e) {
            log.error("网页采集失败,source:{}", source.getName(), e);
        }
        return articles;
    }
}
6.2.3 采集后处理管道

采集到的原始数据需要经过去重、清洗、入库等步骤。

@Slf4j
@Component
public class CrawlPipeline {

    @Resource
    private NewsArticleMapper articleMapper;

    @Resource
    private KafkaTemplate<String, String> kafkaTemplate;

    private static final String TOPIC_ENRICHMENT = "news-enrichment";

    /**
     * 采集后的统一处理管道
     */
    public void process(List<NewsArticle> articles, Long sourceId) {
        int savedCount = 0;
        for (NewsArticle article : articles) {
            // 1. 内容去重
            if (articleMapper.existsByContentHash(article.getContentHash())) {
                continue;
            }
            // 2. 内容清洗(去广告、去特殊字符等)
            article.setContent(cleanContent(article.getContent()));
            // 3. 入库
            articleMapper.insert(article);
            savedCount++;
            // 4. 发送消息到 Kafka,触发异步 AI 处理
            kafkaTemplate.send(TOPIC_ENRICHMENT, article.getId().toString());
        }
        log.info("管道处理完成,sourceId:{},新增:{},去重跳过:{}",
                sourceId, savedCount, articles.size() - savedCount);
    }

    private String cleanContent(String content) {
        if (StrUtil.isEmpty(content)) return content;
        // 去除多余空白
        content = content.replaceAll("\\s{2,}", " ");
        // 去除常见广告文本
        content = content.replaceAll("(广告)|\\[广告\\]", "");
        return content.trim();
    }
}

6.3 AI 文章丰富化

采集完成后,通过 Kafka 消费消息,异步调用大模型对文章进行丰富化处理。

@Slf4j
@Component
public class ArticleEnrichmentService {

    @Resource
    private ChatClient chatClient;

    @Resource
    private EmbeddingService embeddingService;

    @Resource
    private SummaryService summaryService;

    @Resource
    private NewsArticleMapper articleMapper;

    /**
     * 对单篇文章进行 AI 丰富化处理
     */
    public void enrich(NewsArticle article) {
        String content = article.getContent();
        if (StrUtil.isEmpty(content) || content.length() < 50) {
            return;
        }
        // 截断过长内容,避免超出 token 限制
        String truncatedContent = content.length() > 3000
                ? content.substring(0, 3000) + "..." : content;

        // 1. 生成摘要
        String summary = summaryService.generate(truncatedContent);
        // 2. 分类
        String category = classify(truncatedContent);
        // 3. 情感分析
        String sentiment = analyzeSentiment(truncatedContent);
        // 4. 提取标签
        String tags = extractTags(truncatedContent);

        // 更新数据库
        NewsArticle update = new NewsArticle();
        update.setId(article.getId());
        update.setSummary(summary);
        update.setCategory(category);
        update.setSentiment(sentiment);
        update.setTags(tags);
        articleMapper.updateById(update);

        // 5. 异步向量化(存入 Elasticsearch)
        embeddingService.embedAndIndex(article.getId(), truncatedContent, summary, category);

        log.info("文章丰富化完成,articleId:{}", article.getId());
    }

    /**
     * 新闻分类
     */
    private String classify(String content) {
        String prompt = """
                请对以下新闻内容进行分类,从以下类别中选择最匹配的一个:
                [时政, 财经, 科技, 体育, 娱乐, 健康, 教育, 国际, 社会, 军事, 汽车, 房产]
                只返回类别名称,不要其他内容。
                新闻内容:%s
                """.formatted(content);
        return chatClient.prompt()
                .user(prompt)
                .call()
                .content();
    }

    /**
     * 情感分析
     */
    private String analyzeSentiment(String content) {
        String prompt = """
                请分析以下新闻内容的情感倾向,从 positive、negative、neutral 中选择。
                只返回情感标签,不要其他内容。
                新闻内容:%s
                """.formatted(content);
        return chatClient.prompt()
                .user(prompt)
                .call()
                .content();
    }

    /**
     * 标签提取
     */
    private String extractTags(String content) {
        String prompt = """
                请从以下新闻内容中提取3-5个关键标签,以逗号分隔返回。
                标签应为具体的人名、地名、机构名或事件关键词。
                新闻内容:%s
                """.formatted(content);
        return chatClient.prompt()
                .user(prompt)
                .call()
                .content();
    }
}

6.4 智能摘要生成

@Slf4j
@Service
public class SummaryService {

    @Resource
    private ChatClient chatClient;

    /**
     * 生成标准摘要(约150字)
     */
    public String generate(String content) {
        return generate(content, 150);
    }

    /**
     * 生成可控长度摘要
     *
     * @param content  新闻正文
     * @param maxWords 目标字数上限
     */
    public String generate(String content, int maxWords) {
        String prompt = """
                你是一位专业的新闻编辑。请对以下新闻生成一段简洁准确的摘要。

                要求:
                1. 不超过%d个字
                2. 包含核心事件和关键事实(人物、时间、地点、事件)
                3. 保持客观中立的语气
                4. 不要添加原文中没有的信息

                新闻内容:
                %s

                摘要:
                """.formatted(maxWords, content);

        return chatClient.prompt()
                .user(prompt)
                .call()
                .content();
    }

    /**
     * 生成一句话标题摘要
     */
    public String generateHeadline(String content) {
        String prompt = """
                用一句话(不超过30字)概括以下新闻的核心内容:
                %s
                """.formatted(content);
        return chatClient.prompt()
                .user(prompt)
                .call()
                .content();
    }

    /**
     * 生成新闻简报(多篇文章汇总)
     */
    public String generateBriefing(List<NewsArticle> articles) {
        StringBuilder sb = new StringBuilder();
        sb.append("请根据以下多条新闻生成一份简报,每条新闻用1-2句话概括,最后给出今日要闻总结。\n\n");
        for (int i = 0; i < articles.size(); i++) {
            sb.append(String.format("新闻%d [%s] %s\n%s\n\n",
                    i + 1,
                    articles.get(i).getCategory(),
                    articles.get(i).getTitle(),
                    articles.get(i).getSummary()));
        }
        return chatClient.prompt()
                .user(sb.toString())
                .call()
                .content();
    }
}

6.5 向量化与语义检索

6.5.1 文章向量化
@Slf4j
@Service
public class EmbeddingService {

    @Resource
    private EmbeddingModel embeddingModel;

    @Resource
    private ElasticsearchClient esClient;

    private static final String INDEX_NAME = "news_articles";

    /**
     * 将文章向量化并写入 Elasticsearch
     */
    public void embedAndIndex(Long articleId, String content, String summary, String category) {
        // 拼接标题+摘要+正文前500字作为向量化输入
        String textToEmbed = summary + "\n" + content.substring(0, Math.min(content.length(), 500));
        // 调用大模型生成向量
        float[] vector = embeddingModel.embed(textToEmbed);

        // 写入 ES(包含向量字段)
        Map<String, Object> doc = new HashMap<>();
        doc.put("article_id", articleId);
        doc.put("content_vector", vector);
        doc.put("category", category);
        doc.put("text", textToEmbed);

        try {
            esClient.index(i -> i.index(INDEX_NAME).id(articleId.toString()).document(doc));
        } catch (IOException e) {
            log.error("ES索引写入失败,articleId:{}", articleId, e);
        }
    }
}

向量化说明:调用阿里云 DashScope 的 text-embedding-v3 模型,将文本转为 1024 维向量。Spring AI Alibaba 已内置 EmbeddingModel 接口,只需配置即可使用。

6.5.2 Elasticsearch 索引定义
PUT /news_articles
{
  "mappings": {
    "properties": {
      "article_id": { "type": "long" },
      "content_vector": {
        "type": "dense_vector",
        "dims": 1024,
        "index": true,
        "similarity": "cosine"
      },
      "text": { "type": "text", "analyzer": "ik_max_word" },
      "category": { "type": "keyword" },
      "publish_time": { "type": "date" }
    }
  }
}
6.5.3 混合检索(BM25 + 向量)
@Service
public class NewsSearchService {

    @Resource
    private ElasticsearchClient esClient;

    @Resource
    private NewsArticleMapper articleMapper;

    private static final String INDEX_NAME = "news_articles";

    /**
     * 混合检索:BM25 关键词匹配 + 向量语义相似度
     *
     * @param query    用户查询
     * @param category 可选的分类过滤
     * @param size     返回数量
     */
    public List<NewsArticle> hybridSearch(String query, String category, int size) {
        try {
            // 构建混合查询
            NativeQuery searchQuery = NativeQuery.builder()
                    .withQuery(q -> q.bool(b -> {
                        // 子句1:向量语义检索
                        b.must(m -> m.scriptScore(ss -> ss
                                .query(inner -> inner.matchAll(ma -> ma))
                                .script(sc -> sc.inline(si -> si
                                        .source("cosineSimilarity(params.query_vector, 'content_vector') + 1.0")
                                        .params("query_vector", JsonData.of(embedQuery(query)))
                                ))
                        ));
                        // 子句2:BM25 关键词匹配
                        b.should(s -> s.match(m -> m.field("text").query(query)));
                        // 可选:分类过滤
                        if (StrUtil.isNotEmpty(category)) {
                            b.filter(f -> f.term(t -> t.field("category").value(category)));
                        }
                        return b;
                    }))
                    .withMaxResults(size)
                    .build();

            SearchHits<Map> hits = elasticsearchTemplate.search(searchQuery, Map.class);
            return hits.stream()
                    .map(hit -> articleMapper.selectById((Long) hit.getContent().get("article_id")))
                    .filter(Objects::nonNull)
                    .collect(Collectors.toList());
        } catch (Exception e) {
            log.error("混合检索失败,query:{}", query, e);
            return Collections.emptyList();
        }
    }

    /**
     * 将用户查询文本转为向量
     */
    private float[] embedQuery(String query) {
        // TODO: 调用 DashScope Embedding API 将 query 转为向量
        // float[] vector = embeddingModel.embed(query);
        // return vector;
        return new float[0];
    }
}

为什么用混合检索?

  • BM25 精确匹配关键词(人名、地名、专有名词),召回率高
  • 向量检索 理解语义含义(“苹果降价” 和 “iPhone价格调整” 语义相近但关键词不同)
  • 两者结合,既不遗漏精确匹配结果,也能捕获语义相关内容

6.6 智能体编排

利用 Spring AI 的 Tool Calling 和 Agent 能力,将新闻搜索、摘要、翻译等功能封装为工具,由大模型自主调度。

6.6.1 定义工具
@Slf4j
@Component
public class NewsTools {

    @Resource
    private NewsSearchService searchService;

    @Resource
    private SummaryService summaryService;

    @Resource
    private NewsArticleMapper articleMapper;

    @Resource
    private TranslateService translateService;

    /**
     * 工具:搜索新闻
     */
    @Tool(description = "根据关键词搜索新闻,返回最相关的新闻列表。当用户询问新闻、热点、事件时使用。")
    public List<NewsArticle> searchNews(
            @ToolParam(description = "搜索关键词") String query,
            @ToolParam(description = "新闻分类过滤,如:科技、财经、体育。不限制时传null") String category) {
        return searchService.hybridSearch(query, category, 5);
    }

    /**
     * 工具:生成摘要
     */
    @Tool(description = "对指定新闻文章生成摘要。当用户要求总结、概括新闻时使用。")
    public String summarizeArticle(
            @ToolParam(description = "新闻文章ID") Long articleId) {
        NewsArticle article = articleMapper.selectById(articleId);
        if (article == null) return "未找到该文章";
        return summaryService.generate(article.getContent());
    }

    /**
     * 工具:翻译新闻
     */
    @Tool(description = "将新闻内容翻译为指定语言。当用户要求翻译时使用。")
    public String translateNews(
            @ToolParam(description = "新闻文章ID") Long articleId,
            @ToolParam(description = "目标语言,如:en、ja") String targetLanguage) {
        NewsArticle article = articleMapper.selectById(articleId);
        if (article == null) return "未找到该文章";
        return translateService.translate(article.getContent(), targetLanguage);
    }

    /**
     * 工具:生成新闻简报
     */
    @Tool(description = "根据分类和数量生成今日新闻简报。当用户要求看简报、今日要闻时使用。")
    public String generateBriefing(
            @ToolParam(description = "新闻分类,如:科技、财经。all表示全部") String category,
            @ToolParam(description = "简报中包含的新闻条数") int count) {
        List<NewsArticle> articles;
        if ("all".equals(category)) {
            articles = articleMapper.selectTodayTopN(count);
        } else {
            articles = articleMapper.selectTodayByCategory(category, count);
        }
        return summaryService.generateBriefing(articles);
    }
}
6.6.2 对话接口
@Slf4j
@RestController
@RequestMapping("/api/chat")
public class ChatController {

    @Resource
    private ChatClient chatClient;

    @PostMapping
    public String chat(@RequestBody ChatRequest request) {
        String userMessage = request.getMessage();
        Long userId = request.getUserId();

        return chatClient.prompt()
                .system(systemPrompt(userId))
                .user(userMessage)
                .functions("searchNews", "summarizeArticle", "translateNews", "generateBriefing")
                .call()
                .content();
    }

    private String systemPrompt(Long userId) {
        return """
                你是一位专业的新闻助手。你可以帮助用户:
                1. 搜索和查询新闻
                2. 对新闻进行摘要总结
                3. 将新闻翻译为其他语言
                4. 生成新闻简报

                回答要求:
                - 基于检索到的新闻内容回答,不编造信息
                - 引用新闻来源时注明出处
                - 保持客观中立
                - 如果找不到相关新闻,诚实告知用户
                """;
    }
}

工作原理:当用户提问时,大模型会根据问题自动决定调用哪些工具。例如:

用户提问 大模型决策
“最近有什么科技新闻?” 调用 searchNews("科技新闻", "科技")
“帮我总结一下第123条新闻” 调用 summarizeArticle(123L)
“把这条新闻翻译成英文” 调用 translateNews(articleId, "en")
“给我来一份今日财经简报” 调用 generateBriefing("财经", 5)

6.7 语音播报

使用阿里云 CosyVoice 将新闻摘要转为自然语音。

说明:以下为阿里云 CosyVoice TTS 接入预留位置,具体接入方式请参考阿里云文档。

@Slf4j
@Service
public class TtsService {

    @Value("${news.tts.voice:longxiaochun}")
    private String voiceName;

    @Value("${news.tts.output-path:/tmp/news-audio/}")
    private String outputPath;

    @Resource
    private OssUploader ossUploader;

    /**
     * 将文本转为语音文件并上传 OSS
     *
     * @param text      待合成文本
     * @param fileName  文件名
     * @return OSS 音频访问地址
     */
    public String textToSpeech(String text, String fileName) {
        // TODO: 接入阿里云 CosyVoice TTS
        // CosyVoice 使用 WebSocket 协议进行实时语音合成
        // 1. 建立 WebSocket 连接: wss://dashscope.aliyuncs.com/api-ws/v1/inference/
        // 2. 发送文本指令
        // 3. 接收音频流并写入文件
        // 4. 上传至 OSS 返回访问地址
        //
        // 参考模型: fun-cosyvoice-3.0
        // 支持音色: longxiaochun, longshu, longlaotie 等
        //
        // 示例伪代码:
        // String localPath = outputPath + fileName + ".mp3";
        // CosyVoiceClient.synthesize(text, voiceName, localPath);
        // return ossUploader.upload(localPath);

        log.info("TTS合成完成,text长度:{}, fileName:{}", text.length(), fileName);
        return null;
    }
}

6.8 个性化推荐

@Service
public class RecommendService {

    @Resource
    private NewsSearchService searchService;

    @Resource
    private UserProfileMapper profileMapper;

    @Resource
    private UserInteractionMapper interactionMapper;

    @Resource
    private EmbeddingModel embeddingModel;

    /**
     * 基于用户画像的个性化推荐
     */
    public List<NewsArticle> recommend(Long userId, int size) {
        // 1. 获取用户画像
        UserProfile profile = profileMapper.selectByUserId(userId);
        if (profile == null) {
            // 冷启动:返回热门新闻
            return getHotNews(size);
        }

        // 2. 构建用户兴趣向量
        //    将用户偏好的分类和标签拼接,转为向量
        String interestText = StrUtil.join(",",
                profile.getPreferredCategories(),
                profile.getPreferredTags());
        float[] userVector = embeddingModel.embed(interestText);

        // 3. 获取用户最近交互过的文章ID,用于排除
        List<Long> readArticleIds = interactionMapper.selectRecentArticleIds(userId, 50);

        // 4. 基于用户向量进行相似度检索
        return searchService.vectorSearch(userVector, readArticleIds, size);
    }

    /**
     * 热门新闻(冷启动兜底)
     */
    private List<NewsArticle> getHotNews(int size) {
        // 基于阅读量和时间衰减排序
        return Collections.emptyList(); // TODO: 实现热门排序逻辑
    }
}

6.9 用户偏好更新

@RestController
@RequestMapping("/api/user")
public class UserController {

    @Resource
    private UserProfileMapper profileMapper;

    @Resource
    private UserInteractionMapper interactionMapper;

    /**
     * 记录用户行为并更新画像
     */
    @PostMapping("/interact")
    public Result interact(@RequestBody UserInteraction interaction) {
        // 1. 记录交互行为
        interactionMapper.insert(interaction);

        // 2. 更新用户画像(基于最近 N 次行为重新计算偏好)
        List<UserInteraction> recentInteractions = interactionMapper
                .selectRecentByUserId(interaction.getUserId(), 100);
        updateUserProfile(interaction.getUserId(), recentInteractions);

        return Result.success();
    }

    /**
     * 根据最近交互记录更新用户偏好
     * 使用 LLM 从行为记录中提取偏好
     */
    private void updateUserProfile(Long userId, List<UserInteraction> interactions) {
        // 将最近浏览的文章分类和标签汇总
        // 统计高频分类和标签作为偏好
        // 更新 user_profile 表
        // 实际项目中可用 LLM 进行更精细的偏好推断
    }
}

七、异步处理架构

新闻采集到 AI 处理是典型的异步场景,使用 Kafka 解耦:

┌──────────┐    Kafka     ┌───────────────────┐
│  采集服务  │ ──────────> │  news-enrichment  │ ──> AI丰富化
│ (Producer)│             │  (Consumer)        │
└──────────┘              └───────────────────┘
                                         │
                                         ▼
                                  ┌───────────────────┐
                                  │  news-embedding   │ ──> 向量化
                                  │  (Consumer)        │
                                  └───────────────────┘
                                         │
                                         ▼
                                  ┌───────────────────┐
                                  │  news-tts         │ ──> 语音合成
                                  │  (Consumer)        │
                                  └───────────────────┘

Kafka Consumer 示例:

@Slf4j
@Component
public class NewsEnrichmentConsumer {

    @Resource
    private ArticleEnrichmentService enrichmentService;

    @Resource
    private NewsArticleMapper articleMapper;

    @KafkaListener(topics = "news-enrichment", groupId = "news-agent-group")
    public void onMessage(ConsumerRecord<String, String> record) {
        Long articleId = Long.parseLong(record.value());
        try {
            NewsArticle article = articleMapper.selectById(articleId);
            if (article != null) {
                enrichmentService.enrich(article);
            }
        } catch (Exception e) {
            log.error("文章丰富化处理失败,articleId:{}", articleId, e);
        }
    }
}

八、定时任务调度

使用 XXL-JOB 调度新闻采集:

@Slf4j
@Component
public class NewsCrawlJobHandler {

    @Resource
    private NewsSourceMapper sourceMapper;

    @Resource
    private CrawlPipeline crawlPipeline;

    @Resource
    private RssCrawler rssCrawler;

    @Resource
    private WebCrawler webCrawler;

    /**
     * 定时采集所有启用的新闻源
     */
    @XxlJob("newsCrawlJob")
    public void execute() {
        List<NewsSource> sources = sourceMapper.selectEnabled();
        log.info("开始采集,共{}个新闻源", sources.size());

        for (NewsSource source : sources) {
            try {
                List<NewsArticle> articles;
                if (source.getSourceType() == 1) {
                    articles = rssCrawler.crawl(source);
                } else {
                    articles = webCrawler.crawl(source);
                }
                crawlPipeline.process(articles, source.getId());

                // 更新最近采集时间
                NewsSource update = new NewsSource();
                update.setId(source.getId());
                update.setLastCrawlTime(new Date());
                sourceMapper.updateById(update);
            } catch (Exception e) {
                log.error("采集新闻源失败,sourceId:{}", source.getId(), e);
                // 标记异常状态
                NewsSource update = new NewsSource();
                update.setId(source.getId());
                update.setStatus(1);
                sourceMapper.updateById(update);
            }
        }
        log.info("采集任务执行完毕");
    }
}

九、关键 Prompt 设计

Prompt 质量直接决定输出效果,以下是几个核心场景的 Prompt 模板。

9.1 新闻摘要

你是一位拥有10年经验的专业新闻编辑。请对以下新闻内容生成摘要。

规则:
1. 字数控制在{maxWords}字以内
2. 必须包含:核心事件、关键人物、时间地点、结果影响
3. 保持客观中立,不添加个人观点
4. 不编造原文中没有的信息
5. 使用简洁的新闻语言风格

新闻标题:{title}
新闻正文:{content}

请输出摘要:

9.2 新闻分类

对以下新闻进行分类。从给定类别中选择最匹配的一个。

类别列表:时政、财经、科技、体育、娱乐、健康、教育、国际、社会、军事、汽车、房产

新闻内容:{content}

只输出类别名称:

9.3 多语言翻译

你是一位专业的新闻翻译。请将以下新闻翻译为{targetLanguage}。

要求:
1. 准确传达原文含义
2. 保持新闻体裁的语言风格
3. 专有名词(人名、地名、机构名)采用标准译法
4. 数字、日期格式遵循目标语言习惯

原文:
{content}

译文:

9.4 新闻简报

你是一位新闻主播。请根据以下新闻条目,生成一份简明新闻播报稿。

风格要求:
- 开头用一句话概括今日整体要闻
- 每条新闻用1-2句话概括
- 结尾给出简要评论或展望
- 语言简洁有力,适合口语播报

新闻列表:
{articles}

播报稿:

十、性能优化策略

10.1 LLM 调用优化

策略 说明
模型分级 分类/标签用 qwen-turbo(快+便宜),摘要/推理用 qwen-plus
内容截断 新闻正文截取前 3000 字送入 LLM,覆盖核心信息即可
并发调用 多篇文章的丰富化处理使用线程池并行
结果缓存 相同内容的 LLM 结果写入 Redis,避免重复调用
降级策略 LLM 不可用时降级为规则提取(正则匹配分类/标签)

10.2 检索优化

策略 说明
混合检索 BM25 + 向量加权融合,召回率提升 20%+
时间衰减 搜索评分中加入时间权重,新闻越新得分越高
分类预过滤 ES 的 term filter 在向量检索前缩小范围
查询扩展 用 LLM 将用户短查询扩展为更丰富的检索词

10.3 采集优化

策略 说明
增量采集 记录 last_crawl_time,只采集新发布的内容
内容去重 基于内容 MD5 去重,避免重复入库
限速控制 遵守 robots.txt,控制请求频率
失败重试 Kafka 消费失败时重试 3 次,超过则转入死信队列

十一、部署架构

┌─────────────────────────────────────────────────┐
│                    Nginx / SLB                    │
└────────────┬────────────────┬───────────────────┘
             │                │
    ┌────────▼─────┐  ┌──────▼──────┐
    │  News-Agent  │  │  Crawl-Job  │
    │  服务 (x2)   │  │  采集服务(x1)│
    └──────┬───────┘  └──────┬──────┘
           │                 │
           ▼                 ▼
    ┌──────────────────────────────┐
    │         Kafka 集群            │
    └──────────┬───────────────────┘
               │
    ┌──────────▼───────────────────┐
    │      Enrichment Worker (x2)  │
    │    (AI处理消费服务)           │
    └──────────┬───────────────────┘
               │
    ┌──────────▼───────────────────┐
    │         中间件集群             │
    │  MySQL / ES / Redis / OSS    │
    └──────────────────────────────┘
               │
    ┌──────────▼───────────────────┐
    │     阿里云 DashScope API      │
    │  (通义千问 / Embedding / TTS) │
    └──────────────────────────────┘

十二、开发路线图

阶段 内容
Phase 1 基础框架搭建、新闻采集(RSS + 网页)、数据库 CRUD
Phase 2 AI 文章丰富化(摘要、分类、情感、标签)、向量化入库
Phase 3 语义检索(混合检索)、用户画像、个性化推荐
Phase 4 智能体编排(Tool Calling)、对话式新闻助手
Phase 5 语音播报(CosyVoice TTS)、新闻简报定时推送
Phase 6 性能优化、监控告警、前端页面

十三、总结

本文从实际项目出发,介绍了从0到1构建新闻智能体的完整过程。核心思路:

  1. 采集先行:先建立稳定可靠的新闻数据管道
  2. AI 赋能:利用大模型在摘要、分类、检索等环节提效
  3. 智能体编排:通过 Tool Calling 让 LLM 自主调用工具,实现对话式新闻助手
  4. 混合检索:BM25 + 向量结合,兼顾精确匹配和语义理解
  5. 异步解耦:采集与 AI 处理通过 Kafka 解耦,保障系统稳定

技术选型上,Spring AI Alibaba 降低了大模型接入门槛,Elasticsearch 8.x 原生支持向量检索,Java 生态的成熟度也为企业级部署提供了保障。

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐