NLP经典任务的Spring AI实现:分类、抽取、摘要
NLP经典任务的Spring AI实现:分类、抽取、摘要
一、真实故事:GPU账单让他醒悟的那个夜晚
2024年11月,字节跳动离职创业的工程师张杰,看着AWS账单发呆。
账单上,一台配备了A10 GPU的实例,每月费用:$2,340。
这台服务器是为了跑他们自研的NLP系统:一个基于BERT微调的文本分类模型 + 一个基于BERT的命名实体识别模型,部署在自建的Triton推理服务上。
系统性能数据:
- 文本分类准确率:87.3%
- NER的F1分数:0.84
- 平均推理延迟:120ms(BERT模型就是这么慢)
- 每日处理量:约50,000条
为了部署这套系统,张杰的团队:
- 花了3个月标注了20,000条训练数据
- 花了2个月做BERT微调(还搞错了几次超参数)
- 花了1个月搭建推理服务
合计:6个月 + $2,340/月的GPU费用。
然后,一个同行发给他一段代码,用了大约30分钟写的:
String result = chatClient.call(
"请对以下文本进行情感分类,返回JSON:" + text
);测试了100条数据:准确率92.1%,延迟200ms,费用:每1万条约$0.2。
张杰盯着这个结果看了很久,然后关掉了GPU实例。
这篇文章,就是他从BERT转向LLM+提示词工程的完整实践。
二、传统NLP vs LLM NLP的对比
2.1 架构对比
2.2 成本与精度对比
| 维度 | BERT微调 | LLM+提示词 |
|---|---|---|
| 开发周期 | 3-6个月 | 1-2周 |
| 标注成本 | 10-50万(人工标注) | 几乎为零 |
| 基础设施 | GPU服务器 $1000-3000/月 | 无(按量付费) |
| 文本分类准确率 | 85-92% | 88-95% |
| NER F1分数 | 0.80-0.88 | 0.85-0.93 |
| 推理延迟 | 50-200ms(GPU) | 500-2000ms |
| 新任务适应 | 需要重新标注+训练 | 修改提示词(1天) |
结论: 对于大多数NLP任务,LLM+提示词工程在精度、开发效率、成本上已全面优于BERT微调方案。唯一劣势是延迟。
三、情感分析:产品评论情感分类
3.1 需求场景
电商平台每天新增10万+条产品评论,需要自动分类为:
- POSITIVE(正面)
- NEGATIVE(负面)
- NEUTRAL(中性)
- MIXED(混合)
并提取置信度和情感关键词。
3.2 Spring AI结构化输出实现
依赖配置:
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
<version>1.0.0</version>
</dependency>输出结构体:
// SentimentResult.java
public record SentimentResult(
@JsonProperty("sentiment") Sentiment sentiment,
@JsonProperty("confidence") double confidence, // 0.0-1.0
@JsonProperty("positive_keywords") List<String> positiveKeywords,
@JsonProperty("negative_keywords") List<String> negativeKeywords,
@JsonProperty("reasoning") String reasoning
) {
public enum Sentiment {
POSITIVE, NEGATIVE, NEUTRAL, MIXED
}
}情感分析Service:
package com.nlp.service;
import org.springframework.ai.chat.ChatClient;
import org.springframework.ai.chat.prompt.PromptTemplate;
import org.springframework.ai.converter.BeanOutputConverter;
import org.springframework.stereotype.Service;
@Service
@Slf4j
public class SentimentAnalysisService {
private final ChatClient chatClient;
private final BeanOutputConverter<SentimentResult> outputConverter;
public SentimentAnalysisService(ChatClient chatClient) {
this.chatClient = chatClient;
this.outputConverter = new BeanOutputConverter<>(SentimentResult.class);
}
/**
* 单条评论情感分析
*/
public SentimentResult analyzeSentiment(String review) {
String formatInstructions = outputConverter.getFormat();
String prompt = """
你是一位专业的情感分析专家,擅长分析电商产品评论的情感倾向。
请分析以下产品评论的情感,注意:
1. POSITIVE:整体评价正面,可能有小瑕疵但总体满意
2. NEGATIVE:整体评价负面,对产品不满意
3. NEUTRAL:既没有明显好评也没有明显差评
4. MIXED:同时包含明显的正面和负面评价
产品评论:
{review}
{format}
""";
PromptTemplate template = new PromptTemplate(prompt);
String formattedPrompt = template.render(Map.of(
"review", review,
"format", formatInstructions
));
String response = chatClient.call(formattedPrompt);
try {
return outputConverter.convert(response);
} catch (Exception e) {
log.error("情感分析结果解析失败: {}", response, e);
// 降级处理:返回NEUTRAL
return new SentimentResult(
SentimentResult.Sentiment.NEUTRAL, 0.5,
Collections.emptyList(), Collections.emptyList(),
"解析失败,默认返回NEUTRAL"
);
}
}
/**
* 批量情感分析(高效版)
* 将多条评论打包成一次API调用
*/
public List<SentimentResult> batchAnalyze(List<String> reviews) {
if (reviews.isEmpty()) return Collections.emptyList();
// 单次批量最多10条(避免超过token限制)
if (reviews.size() > 10) {
return analyzeLargeList(reviews);
}
// 构建批量提示词
StringBuilder reviewsList = new StringBuilder();
for (int i = 0; i < reviews.size(); i++) {
reviewsList.append(String.format("[%d] %s%n", i + 1, reviews.get(i)));
}
String prompt = String.format("""
分析以下%d条产品评论的情感,返回JSON数组。
评论列表:
%s
返回格式:
[
{"index": 1, "sentiment": "POSITIVE", "confidence": 0.95,
"positive_keywords": ["质量好"], "negative_keywords": [],
"reasoning": "..."},
...
]
""", reviews.size(), reviewsList);
String response = chatClient.call(prompt);
try {
return objectMapper.readValue(response,
new TypeReference<List<SentimentResult>>() {});
} catch (Exception e) {
log.error("批量情感分析解析失败", e);
// 降级为逐条分析
return reviews.stream()
.map(this::analyzeSentiment)
.collect(Collectors.toList());
}
}
private List<SentimentResult> analyzeLargeList(List<String> reviews) {
return ListUtil.partition(reviews, 10).stream()
.flatMap(batch -> batchAnalyze(batch).stream())
.collect(Collectors.toList());
}
}3.3 实际测试效果
张杰用100条标注过的评论测试:
| 情感类别 | 样本数 | BERT准确率 | LLM准确率 |
|---|---|---|---|
| POSITIVE | 45条 | 91.1% | 95.6% |
| NEGATIVE | 32条 | 87.5% | 93.8% |
| NEUTRAL | 15条 | 73.3% | 86.7% |
| MIXED | 8条 | 62.5% | 87.5% |
| 总体 | 100条 | 85.0% | 93.0% |
LLM在混合情感(MIXED)上提升最大,因为这类样本BERT很难学到足够的训练数据。
四、意图识别:客服意图分类
4.1 客服意图体系设计
4.2 多分类+置信度实现
// IntentResult.java
public record IntentResult(
@JsonProperty("primary_intent") String primaryIntent,
@JsonProperty("confidence") double confidence,
@JsonProperty("secondary_intent") String secondaryIntent, // 次要意图
@JsonProperty("secondary_confidence") double secondaryConfidence,
@JsonProperty("entities") Map<String, String> entities, // 抽取的实体
@JsonProperty("urgency") String urgency, // LOW, MEDIUM, HIGH
@JsonProperty("sentiment") String sentiment // 用于调度优先级
) {}
@Service
@Slf4j
public class IntentRecognitionService {
private final ChatClient chatClient;
private final ObjectMapper objectMapper;
// 支持的意图列表(从配置中心加载,方便扩展)
@Value("${intent.categories}")
private List<String> intentCategories;
/**
* 意图识别 - 支持多意图和置信度
*/
public IntentResult recognizeIntent(String userMessage,
String conversationHistory) {
String intentList = String.join("\n", intentCategories.stream()
.map(i -> "- " + i)
.collect(Collectors.toList()));
String prompt = String.format("""
你是一个专业的客服意图识别系统。
可识别的意图类型:
%s
对话历史:
%s
用户当前消息:
%s
请识别用户意图,返回JSON:
{
"primary_intent": "主要意图代码",
"confidence": 0.0-1.0,
"secondary_intent": "次要意图(如有)",
"secondary_confidence": 0.0-1.0,
"entities": {
"order_id": "抽取到的订单号(如有)",
"product_name": "商品名(如有)",
"complaint_type": "投诉类型(如有)"
},
"urgency": "LOW|MEDIUM|HIGH",
"sentiment": "POSITIVE|NEUTRAL|NEGATIVE|ANGRY"
}
注意:
- confidence < 0.6 时,意图识别可能不准确,建议人工确认
- 用户表达愤怒时 urgency 设为 HIGH
""",
intentList,
conversationHistory != null ? conversationHistory : "(无对话历史)",
userMessage
);
String response = chatClient.call(prompt);
try {
return objectMapper.readValue(response, IntentResult.class);
} catch (Exception e) {
log.error("意图识别解析失败: {}", response, e);
return fallbackIntent(userMessage);
}
}
/**
* 基于意图的智能路由
*/
public RoutingDecision route(IntentResult intent, String userId) {
// 置信度太低,转人工
if (intent.confidence() < 0.60) {
return RoutingDecision.toHuman("意图不明确,转人工");
}
// 高优先级处理
if ("HIGH".equals(intent.urgency())
|| "ANGRY".equals(intent.sentiment())) {
return RoutingDecision.toHuman("紧急/愤怒用户,优先人工处理");
}
// 根据意图路由
return switch (intent.primaryIntent()) {
case "ORDER_INQUIRY" -> RoutingDecision.toBot("order-inquiry-bot");
case "RETURN_REQUEST" -> RoutingDecision.toBot("return-bot");
case "COMPLAINT" -> RoutingDecision.toHuman("投诉转人工");
case "PRODUCT_INQUIRY" -> RoutingDecision.toBot("product-bot");
default -> RoutingDecision.toBot("general-bot");
};
}
private IntentResult fallbackIntent(String message) {
return new IntentResult("OTHER", 0.0, null, 0.0,
Collections.emptyMap(), "LOW", "NEUTRAL");
}
}五、命名实体识别:从合同文本抽取关键信息
5.1 法律合同信息抽取场景
合同审核是NLP的高价值场景。从合同中自动抽取:
- 甲乙双方信息
- 合同金额
- 关键日期
- 违约条款
- 保密条款要点
5.2 结构化JSON输出实现
// ContractEntity.java
@JsonIgnoreProperties(ignoreUnknown = true)
public record ContractEntity(
@JsonProperty("party_a") PartyInfo partyA,
@JsonProperty("party_b") PartyInfo partyB,
@JsonProperty("contract_amount") ContractAmount amount,
@JsonProperty("key_dates") KeyDates keyDates,
@JsonProperty("payment_terms") List<String> paymentTerms,
@JsonProperty("confidentiality_terms") List<String> confidentialityTerms,
@JsonProperty("breach_penalties") List<BreachPenalty> breachPenalties,
@JsonProperty("governing_law") String governingLaw,
@JsonProperty("dispute_resolution") String disputeResolution,
@JsonProperty("extraction_confidence") double confidence,
@JsonProperty("missing_fields") List<String> missingFields
) {
public record PartyInfo(
String name, String legalRepresentative,
String address, String uscc // 统一社会信用代码
) {}
public record ContractAmount(
BigDecimal amount, String currency, String amountInWords
) {}
public record KeyDates(
LocalDate signDate, LocalDate effectiveDate,
LocalDate expiryDate, List<String> milestones
) {}
public record BreachPenalty(
String condition, String penalty, String penaltyAmount
) {}
}@Service
@Slf4j
public class ContractExtractionService {
private final ChatClient chatClient;
private final BeanOutputConverter<ContractEntity> converter;
public ContractExtractionService(ChatClient chatClient) {
this.chatClient = chatClient;
this.converter = new BeanOutputConverter<>(ContractEntity.class);
}
/**
* 合同信息抽取(支持长文档)
*/
public ContractEntity extractContractInfo(String contractText) {
// 长合同分段处理
if (contractText.length() > 8000) {
return extractLongContract(contractText);
}
return extractSingleChunk(contractText);
}
private ContractEntity extractSingleChunk(String contractText) {
String formatInstructions = converter.getFormat();
String prompt = String.format("""
你是一位专业的法律文件信息抽取系统,擅长从合同文本中抽取结构化信息。
请从以下合同文本中抽取关键信息:
---合同文本开始---
%s
---合同文本结束---
抽取要求:
1. 所有金额统一转换为人民币数字格式(如:1,000,000.00)
2. 日期统一格式:YYYY-MM-DD
3. 如果某个字段在合同中找不到,请在missing_fields中列出
4. confidence表示整体抽取质量,0.9+表示信息完整,0.7-0.9表示部分缺失
%s
""", contractText, formatInstructions);
String response = chatClient.call(prompt);
try {
return converter.convert(response);
} catch (Exception e) {
log.error("合同信息解析失败", e);
throw new ContractExtractionException("合同信息抽取失败", e);
}
}
/**
* 长合同处理:先分段抽取,再合并
*/
private ContractEntity extractLongContract(String contractText) {
// 按章节分割
List<String> sections = splitIntoSections(contractText);
List<ContractEntity> sectionResults = sections.stream()
.map(section -> {
try {
return extractSingleChunk(section);
} catch (Exception e) {
log.warn("章节抽取失败,跳过", e);
return null;
}
})
.filter(Objects::nonNull)
.collect(Collectors.toList());
// 合并所有章节的抽取结果
return mergeContractEntities(sectionResults);
}
/**
* 合并多段抽取结果
*/
private ContractEntity mergeContractEntities(List<ContractEntity> entities) {
// 用甲方信息最完整的那一段
ContractEntity.PartyInfo partyA = entities.stream()
.map(ContractEntity::partyA)
.filter(Objects::nonNull)
.max(Comparator.comparingInt(p ->
(p.name() != null ? 1 : 0) +
(p.uscc() != null ? 1 : 0) +
(p.address() != null ? 1 : 0)))
.orElse(null);
ContractEntity.PartyInfo partyB = entities.stream()
.map(ContractEntity::partyB)
.filter(Objects::nonNull)
.max(Comparator.comparingInt(p ->
(p.name() != null ? 1 : 0) +
(p.uscc() != null ? 1 : 0)))
.orElse(null);
// 金额取最大的那个(通常是合同总金额)
ContractEntity.ContractAmount amount = entities.stream()
.map(ContractEntity::amount)
.filter(a -> a != null && a.amount() != null)
.max(Comparator.comparing(a -> a.amount()))
.orElse(null);
// 合并所有支付条款和违约条款
List<String> allPaymentTerms = entities.stream()
.flatMap(e -> e.paymentTerms() != null ? e.paymentTerms().stream() : Stream.empty())
.distinct()
.collect(Collectors.toList());
List<ContractEntity.BreachPenalty> allBreachPenalties = entities.stream()
.flatMap(e -> e.breachPenalties() != null ? e.breachPenalties().stream() : Stream.empty())
.collect(Collectors.toList());
double avgConfidence = entities.stream()
.mapToDouble(ContractEntity::confidence)
.average()
.orElse(0.0);
return new ContractEntity(
partyA, partyB, amount,
findBestKeyDates(entities),
allPaymentTerms,
Collections.emptyList(),
allBreachPenalties,
findGoverningLaw(entities),
findDisputeResolution(entities),
avgConfidence,
Collections.emptyList()
);
}
}六、关系抽取:从新闻中抽取实体关系
6.1 场景说明
从财经新闻中自动抽取实体关系,用于构建知识图谱:
新闻文本:
"阿里巴巴以43亿美元收购了饿了么,
完成后,张勇将担任饿了么CEO。"
期望输出:
[
{"subject": "阿里巴巴", "predicate": "收购", "object": "饿了么",
"value": "43亿美元"},
{"subject": "张勇", "predicate": "担任", "object": "饿了么CEO"}
]6.2 复杂关系抽取提示词设计
@Service
@Slf4j
public class RelationExtractionService {
private final ChatClient chatClient;
private final ObjectMapper objectMapper;
// 关系类型定义(可扩展)
private static final List<String> RELATION_TYPES = List.of(
"ACQUISITION", // 收购/并购
"INVESTMENT", // 投资
"COOPERATION", // 合作
"APPOINTMENT", // 任命
"RESIGNATION", // 离职
"LAWSUIT", // 起诉/被起诉
"PARTNERSHIP", // 合伙/联盟
"SUBSIDIARY" // 子公司关系
);
public List<RelationTriple> extractRelations(String newsText) {
String relationTypesDesc = RELATION_TYPES.stream()
.collect(Collectors.joining("、"));
String prompt = String.format("""
你是一位专业的信息抽取专家,擅长从财经新闻中抽取实体关系三元组。
关系类型包括:%s
新闻文本:
%s
请抽取文本中所有实体关系,返回JSON数组:
[
{
"subject": "主体实体名称",
"subject_type": "COMPANY|PERSON|GOVERNMENT|OTHER",
"predicate": "关系类型(从上述类型中选择,或自定义)",
"object": "客体实体名称",
"object_type": "COMPANY|PERSON|GOVERNMENT|OTHER",
"value": "关系的定量描述(如金额、比例,没有则为null)",
"time": "关系发生时间(YYYY-MM-DD,没有则为null)",
"source_sentence": "原文中的依据句子(精确引用)",
"confidence": 0.0-1.0
}
]
要求:
1. 只抽取文本中明确提到的关系,不要推断
2. 每个三元组都要有source_sentence作为证据
3. confidence < 0.7 的关系不要返回
4. 如果文本中没有明显关系,返回空数组 []
""",
relationTypesDesc,
newsText
);
String response = chatClient.call(prompt);
try {
// 清理可能的markdown代码块
String cleanedResponse = cleanJsonResponse(response);
return objectMapper.readValue(cleanedResponse,
new TypeReference<List<RelationTriple>>() {});
} catch (Exception e) {
log.error("关系抽取解析失败: {}", response, e);
return Collections.emptyList();
}
}
/**
* 清理LLM可能返回的markdown格式
*/
private String cleanJsonResponse(String response) {
String cleaned = response.trim();
if (cleaned.startsWith("```json")) {
cleaned = cleaned.substring(7);
} else if (cleaned.startsWith("```")) {
cleaned = cleaned.substring(3);
}
if (cleaned.endsWith("```")) {
cleaned = cleaned.substring(0, cleaned.length() - 3);
}
return cleaned.trim();
}
}七、文本摘要:长文档自动摘要(Map-Reduce方法)
7.1 Map-Reduce摘要架构
7.2 完整Map-Reduce摘要实现
@Service
@Slf4j
public class DocumentSummarizationService {
private final ChatClient chatClient;
private static final int MAX_CHUNK_SIZE = 2000; // 每块字符数
private static final int CHUNK_OVERLAP = 200; // 块重叠字符数
/**
* 自动摘要主入口
* 根据文本长度自动选择策略
*/
public SummarizationResult summarize(String document,
SummarizationConfig config) {
int docLength = document.length();
log.info("开始摘要,文档长度: {}字", docLength);
if (docLength <= 3000) {
// 短文档直接摘要
return directSummarize(document, config);
} else {
// 长文档使用Map-Reduce
return mapReduceSummarize(document, config);
}
}
/**
* 短文档直接摘要
*/
private SummarizationResult directSummarize(String document,
SummarizationConfig config) {
String prompt = buildSummarizationPrompt(document, config);
String summary = chatClient.call(prompt);
return SummarizationResult.builder()
.summary(summary)
.strategy("DIRECT")
.originalLength(document.length())
.summaryLength(summary.length())
.compressionRatio((double) summary.length() / document.length())
.build();
}
/**
* 长文档Map-Reduce摘要
*/
private SummarizationResult mapReduceSummarize(String document,
SummarizationConfig config) {
// Map阶段:分割并逐块摘要
List<String> chunks = splitDocument(document);
log.info("文档分为{}块", chunks.size());
// 并行Map(提高处理速度)
List<String> chunkSummaries = chunks.parallelStream()
.map(chunk -> {
String mapPrompt = String.format("""
请对以下文本段落进行简洁摘要,保留关键信息,不超过200字:
%s
""", chunk);
try {
return chatClient.call(mapPrompt);
} catch (Exception e) {
log.error("块摘要失败", e);
return chunk.substring(0, Math.min(200, chunk.length()));
}
})
.collect(Collectors.toList());
// 如果合并后的摘要还是太长,递归处理
String combined = String.join("\n\n", chunkSummaries);
if (combined.length() > 5000) {
log.info("中间摘要仍较长({}字),进行第二轮Map-Reduce", combined.length());
return mapReduceSummarize(combined, config);
}
// Reduce阶段:合并所有块摘要生成最终摘要
String reducePrompt = String.format("""
以下是一篇长文档各段落的摘要,请综合所有信息,
生成一篇连贯、完整的最终摘要。
要求:
- 摘要长度:%s
- 重点突出:%s
- 语言风格:%s
各段落摘要:
%s
""",
config.getTargetLength(),
config.getFocusPoints() != null ? String.join("、", config.getFocusPoints()) : "所有重要信息",
config.getStyle() != null ? config.getStyle() : "客观简洁",
combined
);
String finalSummary = chatClient.call(reducePrompt);
return SummarizationResult.builder()
.summary(finalSummary)
.strategy("MAP_REDUCE")
.chunksCount(chunks.size())
.originalLength(document.length())
.summaryLength(finalSummary.length())
.compressionRatio((double) finalSummary.length() / document.length())
.build();
}
/**
* 文本分割(带重叠,避免截断关键信息)
*/
private List<String> splitDocument(String document) {
List<String> chunks = new ArrayList<>();
int start = 0;
while (start < document.length()) {
int end = Math.min(start + MAX_CHUNK_SIZE, document.length());
// 尽量在句子结束处分割(避免截断句子)
if (end < document.length()) {
int lastPeriod = document.lastIndexOf('。', end);
int lastNewline = document.lastIndexOf('\n', end);
int splitPoint = Math.max(lastPeriod, lastNewline);
if (splitPoint > start + MAX_CHUNK_SIZE / 2) {
end = splitPoint + 1; // +1 包含句号
}
}
chunks.add(document.substring(start, end));
start = end - CHUNK_OVERLAP; // 重叠区域
if (start >= document.length()) break;
}
return chunks;
}
private String buildSummarizationPrompt(String document,
SummarizationConfig config) {
return String.format("""
请对以下文档进行摘要。
要求:
- 目标长度:%s字以内
- 语言:简洁客观
- 重点保留:关键数字、结论、行动项
文档内容:
%s
""",
config.getTargetLength() != null ? config.getTargetLength() : "300",
document
);
}
}八、文本分割策略:语义分割 vs 固定长度分割
8.1 两种分割策略对比
@Component
public class TextSplitter {
/**
* 固定长度分割(简单高效,适合处理速度优先)
*/
public List<String> fixedSizeSplit(String text, int chunkSize, int overlap) {
List<String> chunks = new ArrayList<>();
int start = 0;
while (start < text.length()) {
int end = Math.min(start + chunkSize, text.length());
chunks.add(text.substring(start, end));
start = end - overlap;
if (start < 0 || start >= text.length()) break;
}
return chunks;
}
/**
* 语义分割(质量更高,适合RAG场景)
* 按自然段落、句子边界分割,保持语义完整性
*/
public List<String> semanticSplit(String text, int maxChunkSize) {
// 1. 先按自然段落分割(空行)
String[] paragraphs = text.split("\\n{2,}");
List<String> chunks = new ArrayList<>();
StringBuilder currentChunk = new StringBuilder();
for (String paragraph : paragraphs) {
// 段落本身超过maxChunkSize,按句子分割
if (paragraph.length() > maxChunkSize) {
// 先保存当前chunk
if (currentChunk.length() > 0) {
chunks.add(currentChunk.toString().trim());
currentChunk = new StringBuilder();
}
// 按句子分割长段落
List<String> sentences = splitSentences(paragraph);
StringBuilder sentenceChunk = new StringBuilder();
for (String sentence : sentences) {
if (sentenceChunk.length() + sentence.length() > maxChunkSize) {
if (sentenceChunk.length() > 0) {
chunks.add(sentenceChunk.toString().trim());
sentenceChunk = new StringBuilder();
}
}
sentenceChunk.append(sentence);
}
if (sentenceChunk.length() > 0) {
chunks.add(sentenceChunk.toString().trim());
}
} else if (currentChunk.length() + paragraph.length() > maxChunkSize) {
// 当前chunk加上这段会超长,先保存
if (currentChunk.length() > 0) {
chunks.add(currentChunk.toString().trim());
currentChunk = new StringBuilder();
}
currentChunk.append(paragraph).append("\n\n");
} else {
currentChunk.append(paragraph).append("\n\n");
}
}
if (currentChunk.length() > 0) {
chunks.add(currentChunk.toString().trim());
}
return chunks;
}
/**
* 按中文句子分割
*/
private List<String> splitSentences(String text) {
// 按。!?等中文句末标点分割
String[] sentences = text.split("(?<=[。!?;\\.!?;])");
return Arrays.asList(sentences);
}
}8.2 分割策略选择建议
| 场景 | 推荐策略 | 理由 |
|---|---|---|
| RAG知识库 | 语义分割 | 保证语义完整性,提升召回质量 |
| 批量摘要 | 固定长度(带重叠) | 速度优先,重叠避免信息丢失 |
| 合同分析 | 章节分割 | 按合同结构分割,保持逻辑完整 |
| 新闻处理 | 段落分割 | 新闻段落本身就是语义单元 |
九、Few-shot学习:用3-5个示例让LLM学会新任务
9.1 Few-shot的核心思想
当标准提示词不够精准时,提供3-5个示例(输入-输出对),LLM会快速学习任务模式:
@Service
public class FewShotNlpService {
private final ChatClient chatClient;
// 行业特定情感分析(电商专用,带few-shot示例)
private static final String ECOMMERCE_SENTIMENT_PROMPT_TEMPLATE = """
你是一位电商产品评论情感分析专家。
以下是一些示例:
评论:这件衣服质量很好,穿着舒适,但是颜色和图片有点色差,建议商家改进。
分析:{"sentiment": "MIXED", "confidence": 0.92, "positive": ["质量好", "舒适"], "negative": ["色差"]}
评论:假货!包装破损,快递员态度也很差,差评!
分析:{"sentiment": "NEGATIVE", "confidence": 0.99, "positive": [], "negative": ["假货", "包装破损", "态度差"]}
评论:第二次购买,一如既往的好,价格也实惠,推荐!
分析:{"sentiment": "POSITIVE", "confidence": 0.97, "positive": ["多次购买", "价格实惠"], "negative": []}
评论:还行吧,跟预期差不多,没有惊喜也没有失望。
分析:{"sentiment": "NEUTRAL", "confidence": 0.88, "positive": [], "negative": []}
现在请分析以下评论(返回相同JSON格式):
评论:{review}
分析:
""";
/**
* 使用Few-shot示例的情感分析
*/
public Map<String, Object> analyzeWithFewShot(String review) {
String prompt = ECOMMERCE_SENTIMENT_PROMPT_TEMPLATE.replace("{review}", review);
String response = chatClient.call(prompt);
try {
return objectMapper.readValue(response, Map.class);
} catch (Exception e) {
log.error("Few-shot分析解析失败", e);
return Map.of("sentiment", "UNKNOWN", "confidence", 0.0);
}
}
/**
* 动态Few-shot:从数据库加载高质量示例
* 适合业务持续迭代的场景
*/
public String buildDynamicFewShotPrompt(String task,
String input,
int examplesCount) {
// 从数据库加载高质量人工标注示例
List<FewShotExample> examples = exampleRepository
.findByTaskAndQualityScore(task, 0.9, examplesCount);
StringBuilder promptBuilder = new StringBuilder();
promptBuilder.append("以下是一些任务示例:\n\n");
for (FewShotExample example : examples) {
promptBuilder.append("输入:").append(example.getInput()).append("\n");
promptBuilder.append("输出:").append(example.getOutput()).append("\n\n");
}
promptBuilder.append("现在请处理以下输入(按照示例格式输出):\n");
promptBuilder.append("输入:").append(input).append("\n");
promptBuilder.append("输出:");
return promptBuilder.toString();
}
}十、批量处理:大规模NLP任务的并发处理
10.1 线程池设计
@Configuration
public class NlpThreadPoolConfig {
@Bean("nlpTaskExecutor")
public ThreadPoolTaskExecutor nlpTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
// 核心线程数:根据API并发限制设置
// OpenAI免费层: 3 RPM → corePoolSize = 3
// 付费层Tier1: 500 RPM → corePoolSize = 20
executor.setCorePoolSize(20);
executor.setMaxPoolSize(50);
executor.setQueueCapacity(1000);
executor.setThreadNamePrefix("nlp-task-");
executor.setKeepAliveSeconds(60);
// 拒绝策略:调用者运行(防止任务丢失)
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
}@Service
@Slf4j
public class BulkNlpProcessingService {
@Autowired
@Qualifier("nlpTaskExecutor")
private Executor nlpExecutor;
private final SentimentAnalysisService sentimentService;
private final IntentRecognitionService intentService;
/**
* 大规模批量NLP处理
* 支持进度回调
*/
public CompletableFuture<BulkProcessingResult> processInBulk(
List<NlpTask> tasks,
Consumer<BulkProcessingProgress> progressCallback) {
int totalTasks = tasks.size();
AtomicInteger completedCount = new AtomicInteger(0);
AtomicInteger failedCount = new AtomicInteger(0);
List<NlpTaskResult> results = Collections.synchronizedList(new ArrayList<>());
// 限速器:控制API调用速率
RateLimiter rateLimiter = RateLimiter.create(10.0); // 每秒最多10个请求
List<CompletableFuture<Void>> futures = tasks.stream()
.map(task -> CompletableFuture.runAsync(() -> {
// 等待限速令牌
rateLimiter.acquire();
try {
NlpTaskResult result = processTask(task);
results.add(result);
int completed = completedCount.incrementAndGet();
// 每完成10%触发一次进度回调
if (completed % (totalTasks / 10) == 0) {
progressCallback.accept(BulkProcessingProgress.builder()
.total(totalTasks)
.completed(completed)
.failed(failedCount.get())
.progressPercent((double) completed / totalTasks * 100)
.build());
}
} catch (Exception e) {
failedCount.incrementAndGet();
log.error("任务{}处理失败: {}", task.getId(), e.getMessage());
// 失败任务加入重试队列
retryQueue.offer(task);
}
}, nlpExecutor))
.collect(Collectors.toList());
return CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
.thenApply(v -> BulkProcessingResult.builder()
.totalTasks(totalTasks)
.completedTasks(completedCount.get())
.failedTasks(failedCount.get())
.results(results)
.build()
);
}
private NlpTaskResult processTask(NlpTask task) {
return switch (task.getType()) {
case SENTIMENT_ANALYSIS ->
NlpTaskResult.from(task, sentimentService.analyzeSentiment(task.getText()));
case INTENT_RECOGNITION ->
NlpTaskResult.from(task, intentService.recognizeIntent(task.getText(), null));
case SUMMARIZATION ->
NlpTaskResult.from(task, summarizationService.summarize(task.getText(),
SummarizationConfig.defaultConfig()));
default -> throw new UnsupportedOperationException("不支持的任务类型: " + task.getType());
};
}
}十一、成本对比:LLM vs 传统NLP模型
11.1 真实成本数据(张杰团队数据)
场景:每日处理50,000条文本(情感分析+意图识别)
| 方案 | 月费用(元) | 延迟P50 | 准确率 |
|---|---|---|---|
| GPU服务器(A10 + BERT) | 16,380元 | 120ms | 87% |
| OpenAI GPT-4o-mini | 约2,800元 | 800ms | 93% |
| 阿里通义千问-Turbo | 约1,200元 | 600ms | 91% |
| 混合策略(高置信走规则,低置信走LLM) | 约800元 | 150ms均值 | 92% |
混合策略的逻辑:
@Service
public class HybridNlpStrategy {
private final RuleBasedClassifier ruleClassifier; // 快速规则
private final LlmNlpService llmService; // LLM兜底
/**
* 混合策略:规则优先,LLM兜底
* 目标:80%的请求走规则(低成本高速度),20%走LLM(高准确率)
*/
public ClassificationResult classify(String text) {
// 尝试规则分类
ClassificationResult ruleResult = ruleClassifier.classify(text);
// 规则置信度够高,直接返回(无API调用)
if (ruleResult.getConfidence() >= 0.90) {
return ruleResult.withSource("RULE");
}
// 置信度不足,升级到LLM
ClassificationResult llmResult = llmService.classify(text);
return llmResult.withSource("LLM");
}
}测试结果:
- 81%的请求走规则(0成本)
- 19%的请求走LLM($0.2/万条)
- 整体准确率:91.8%(比纯规则高6%,比纯GPU低0.2%)
- 月费用:约800元(比GPU方案节省95%)
FAQ
Q1:LLM处理NLP任务延迟高怎么办?
延迟优化策略:
- 缓存相同文本的处理结果(Redis,TTL 24小时)
- 批量处理(10条合并一次API调用,延迟除以10)
- 使用更快的模型(GPT-4o-mini vs GPT-4,速度提升5倍)
- 预取(异步预先处理可能被请求的内容)
Q2:如何处理LLM输出不稳定(同一输入偶尔返回不同格式)?
三层防御:
- 明确的输出格式要求(JSON Schema)
- 输出验证(Jackson反序列化失败时降级)
- 重试机制(最多3次,第3次时降低temperature到0)
Q3:提示词被"注入"怎么办(用户输入影响NLP结果)?
// 输入净化
public String sanitizeInput(String userInput) {
// 转义特殊字符
return userInput
.replace("请忽略以上指令", "[filtered]")
.replace("忘记你的系统提示", "[filtered]")
// 截断过长输入(防止prompt injection)
.substring(0, Math.min(2000, userInput.length()));
}
// 使用结构化消息(更安全)
UserMessage userMsg = new UserMessage(sanitizeInput(input));
// 用户输入和指令在不同消息中,不能互相影响Q4:中文NLP用哪个模型效果最好?
根据张杰团队的测试结果:
- 通义千问-Max:中文NLP综合最好,推荐首选
- GPT-4o:中英文均衡,英文场景首选
- GLM-4:中文效果接近通义,成本更低
- 豆包(字节):速度快,适合批量处理
总结
从BERT微调到LLM+提示词工程,NLP的开发范式已经发生了根本性转变:
传统NLP路径:
标注数据 → BERT微调 → GPU部署 → 性能调优
(6个月 + 高昂GPU成本)
新范式:
业务理解 → 提示词设计 → Few-shot示例 → 生产部署
(1-2周 + 按量付费)关键结论:
- 情感分析/意图识别:LLM全面优于BERT,直接替换
- NER/关系抽取:LLM结构化输出+提示词优化,效果更好
- 长文档摘要:Map-Reduce方法完美解决token限制
- 成本控制:混合策略(规则+LLM)可节省90%成本
张杰关掉GPU服务器的那天,不是在做减法,而是在做正确的技术选择。
