大概两年之前,我们有一个快递类型的业务场景,为了简化用户的操作,为用户提供了一个快捷功能,支持用户直接粘贴一段自然语言的文本,然后由程序后台基于这段文本自动提取出结构化的地址信息,在方式大模型还未普及,我们使用的是baidu的一个收费接口
虽然价格不贵,但总归也是收费不是;这个场景非常垂直聚焦,正好也可以作为我们应用集成LLM的一个实验田。接下来我将介绍下如何利用 Spring AI 框架结合大模型能力,实现从自然语言文本中自动提取结构化地址信息的完整方案,并且通过function call实时查询行政编码,从而打造一个完整可直接商用的 “地址提取智能体”
一、整体架构设计
1.1 技术栈选型
- SpringAI 1.1.2:统一AI调用框架
- ZhiPu:使用它的免费版大模型
GLM-4-Flash - Function Calling:让大模型具备调用外部API的能力
- SpringBoot 3.x:现代化Java框架
- JDK17+: java版本最低要求17
- 行政区划编码库:Administrative-divisions-of-China
1.2 系统架构
从整体的视角来看,这个地址信息的提取结构相对清晰,基于用户输入的自然语言、通过LLM获取结构化的地址信息,然后返回给用户;
结合应用程序对地址的使用策略,我们在基本中文地址提取之外,通过Function Calling机制,实现让大模型返回的结构化信息中,包含行政区域代码,这样更方便将大模型返回的地址信息与项目中自己维护的地址信息进行映射;
说明:返回行政区域编码,主要是为了避免出现 本地地址库中存
湖北省、武汉市,但是大模型返回的是湖北、武汉这类文本,导致后台程序无法精确根据地址文本进行映射的场景;通过统一的行政区域编码,则可以有效规避这种场景
下面是整体的结构图
二、核心实现步骤
首先我们需要搭建要给SpringAI的项目,不太熟悉的小伙伴可以参照 01.创建一个SpringAI的示例工程 | 一灰灰的站点 来完成
在下面的实现过程中,我们使用智谱的免费大模型作为我们的实际载体;若希望使用其他的模型的小伙伴,也可以直接替换(SpringAI对不同厂商的大模型集成得相当可以,切换成本较低)
2.1 环境配置与依赖
我们使用的SpringAI的版本为最新的 1.1.2 ,此外直接使用 zhipu 的starter来作为大模型的交互客户端
<!-- pom.xml 关键依赖 -->
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-zhipuai</artifactId>
</dependency>
</dependencies>
然后在配置文件中,设置对应的配置信息,其中关键点为
- api-key: 可以通过启动参数、系统环境变量等方式注入key,从而避免硬编码导致的泄露问题
- model: 选择的是免费的
GLM-4-Flash, 支持function call(说明:若你选中的模型不支持函数调用,那么就无法实现后续的行政区域查询的工具注入)
# application.yml
spring:
ai:
zhipuai:
# api-key 使用你自己申请的进行替换;如果为了安全考虑,可以通过启动参数进行设置
api-key: ${zhipuai-api-key}
chat:
options:
model: GLM-4-Flash
# 修改日志级别
logging:
level:
org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor: debug
2.2 地址数据结构设计
首先定义一下我们希望接受的结构化返回数据,以常见的快递物流的地址信息为例,关键信息包含:
- 省
- 市
- 区
- 街道
- 详细地址
- 用户
- 手机号
record Address(
@JsonPropertyDescription("省,如 湖北省")
String province,
@JsonPropertyDescription("市,如 武汉市")
String city,
@JsonPropertyDescription("区,如 武昌区")
String area,
@JsonPropertyDescription("街道,如 东湖路")
String street,
@JsonPropertyDescription("行政区域编码,如 420106")
String adCode,
@JsonPropertyDescription("详细地址,如 发财无限公司8栋8单元888号")
String detailInfo,
@JsonPropertyDescription("联系人,如 张三")
String personName,
@JsonPropertyDescription("联系人电话,如 15345785872")
String personPhone
) {
}
2.3 快速原型实现
接下来我们看一下直接使用大模型本身的文本提取能力,快速实现一个基础的原型
@RestController
public class ChatController {
private final ChatModel chatModel;
@Autowired
public ChatController(ChatModel chatModel) {
this.chatModel = chatModel;
}
/**
* 从传入的自然语言中,提取出地址信息
*
* @param content
* @return
*/
@GetMapping("/ai/genAddress")
public Address generateAddress(String content) {
BeanOutputConverter<Address> beanOutputConverter = new BeanOutputConverter<>(Address.class);
String format = beanOutputConverter.getFormat();
PromptTemplate template = new PromptTemplate("请从下面个你的文本中,帮我提取详细的地址信息,要求中文返回: \n\n地址信息:\n{area} \n\n返回格式:{format}");
Prompt prompt = template.create(Map.of("area", content, "format", format));
Generation generation = chatModel.call(prompt).getResult();
if (generation == null) {
return null;
}
return beanOutputConverter.convert(generation.getOutput().getText());
}
}
在上面的实现中,我们直接使用 ChatModel 进行大模型的交互,在这不到10行的代码中,主要使用到两个技术点
- 提示词模板: PromptTemplate 实现占位替换
- 结果化返回: 通过
BeanOutputConverter实现bean对象转json schema,然后通过提示词工程约束大模型返回;同时也通过Converter实现返回结果转Bean对象
接下来看一下试验效果
# 用于测试的文本,示例来源于 https://ai.baidu.com/tech/nlp_apply/address
礼盒20个吉林省长春市朝阳区开运街领秀朝阳小区 田甜 18692093383
玲 18682085605 广州市天河区迎福路527号广东金融学院
从上面的实际表现也可以看出,有两个明显的问题
- 行政区域编码返回的不一定准确:如上面的
长春市朝阳区的行政区域编码返回就不对 - 详细地址提取不一定准确:上图中广州市的这个地址,两次请求的返回不一样,且结果不完整
从这个快速原型我们也可以看出,基于大模型做地址识别方向可行,但是还需要“调教” ———— 即需要有一个更稳定、高质量输出的提示词设计
2.4 Prompt工程:引导模型输出的准确的结果
上面demo中的提示词过于简略,因此我们需要设计一个更符合工程化的提示词(有兴趣的小伙伴可以查看 大模型应用开发系列教程: 第五章 从 Prompt 到 Prompt 模板与工程治理 看看如何设计更推荐的提示词)
由于这个场景的目标非常清晰,所以对应的设计可以从下面几个出发
- 角色:地址信息提取专家
- 约束:一些地址相关的提取规则
- 结构化:返回的定义
- 少样本学习:提供示例,用于少样本学习
然后在上面的基础上,我们改造一下具体的实现策略
@RestController
public class ChatController {
private static final String SYSTEM_PROMPT = """
你是一个专业的地址信息提取专家。请从用户输入的自然语言文本中提取结构化地址信息。
提取规则:
1. 识别并分离出:省份、城市、区县、街道、详细地址
2. 地址组件可能存在简称、别称,请转换为标准名称
3. 如果用户输入包含"省"、"市"、"区"、"县"等关键词,需正确处理
输出格式要求:
- 省份:完整省份名称,如"广东省"
- 城市:地级市名称,直辖市填"北京市"等
- 区县:区或县级市名称
- 街道:街道、乡镇名称
- 详细地址:门牌号、小区、楼栋等
- 行政区域编码:通常是区县一级的编码,6位数字
示例输入:"礼盒20个吉林省长春市朝阳区开运街领秀朝阳小区 田甜 18692093383"
示例输出: {
"province": "吉林省",
"city": "长春市",
"area": "朝阳区",
"street": "开运街领秀朝阳小区",
"adCode": "220104",
"personName": "田甜",
"personPhone": "18692093383"
}
""";
private final ChatModel chatModel;
private final ChatClient chatClient;
@Autowired
public ChatController(ChatModel chatModel) {
this.chatModel = chatModel;
this.chatClient = ChatClient.builder(chatModel)
.defaultAdvisors(new SimpleLoggerAdvisor(ModelOptionsUtils::toJsonStringPrettyPrinter, ModelOptionsUtils::toJsonStringPrettyPrinter, 0))
.defaultSystem(SYSTEM_PROMPT)
.build();
}
@GetMapping("/ai/genAddressWithPromptTemplate")
public Address generateAddressWithPromptTemplate(String content) {
ChatClient.CallResponseSpec res = chatClient.prompt(content).call();
Address address = res.entity(Address.class);
return address;
}
}
在这个实现中,我们直接提取了一个提示词模板 SYSTEM_PROMPT (虽然上面的代码是直接硬编码的方式写成的,但是更推荐的是使用更专业的提示词管理服务来维护、比如支持版本、灰度对比等)
其次就是具体的LLM交互中,我们使用 ChatClient 替换了更原生的 ChatModel,因为它对系统提示词、结构化返回从使用角度封装得更友好,使用起来更简单;从代码量也可以看出,三行就完前面同等的调用过程
同样的我们来看看具体表现,依然使用前面的测试文本
从上面的结果来看,广州市这个文本的详细地址解析就稳定可靠多了;但是吉林这个地址行政区域编码错误的问题依旧,显然提示词本身是无法纠正大模型的错误资料库的
所以接下来我们就需要给大模型安上“作弊器”,让它有能力查询到正确的行政区域编码
2.5 Function Call实现:行政区划编码查询
虽然有一些API提供了行政区域编码(比如高德),但是为了减少外部依赖(当然也是为了白嫖),我们选择使用标准的行政编码库来提供自己的查询服务
首先是从开源项目中获取数据集:https://github.com/modood/Administrative-divisions-of-China
为了准确性,我们选择的是
pca-code.json这个包含 省、市、区 三级联动的数据库(因为街道的变动频率相比较于这三级高太多了...)
这个字典的数据模型形如
[
{
"code": "11",
"name": "北京市",
"children": [
{
"code": "1101",
"name": "市辖区",
"children": [
{
"code": "110101",
"name": "东城区"
},
{
"code": "110102",
"name": "西城区"
}
]
}
]
}
]
因此我们可以实现一个简单基于内存的行政区域查询服务
- 读取字典、映射为结构化数据
- 解析为Map格式,方便快速查找
下面就是一个基础的初始化实现过程(阅读起来有困难的小伙伴不妨结合AICoding解释一下)
@Service
public class AddressAdCodeService {
private final Logger log = org.slf4j.LoggerFactory.getLogger(AddressAdCodeService.class);
private final static String data = "data/pca-code.json";
private volatile Map<String, ProvinceMapper> provinceMap;
/**
* 从 data/pca-code.json 中加载数据,并结构化
*/
@PostConstruct
public void init() {
try (InputStream stream = this.getClass().getClassLoader().getResourceAsStream(data)) {
// 读取数据
String content = IOUtils.toString(stream, UTF_8);
ObjectMapper mapper = new ObjectMapper();
// 将content反序列化为 List<Province>
List<Province> provinces = mapper.readValue(content, mapper.getTypeFactory().constructCollectionType(List.class, Province.class));
// 构建结构化数据,方便快速查找
HashMap<String, ProvinceMapper> map = new HashMap<>();
for (Province province : provinces) {
ProvinceMapper provinceMap = new ProvinceMapper(province.code, province.name, new HashMap<>());
map.put(province.name, provinceMap);
for (City city : province.children) {
Map<String, Area> areaMap = CollectionUtils.isEmpty(city.children) ? Map.of() : city.children.stream().collect(Collectors.toMap(s -> s.name, s -> s));
CityMapper cityMap = new CityMapper(city.code, city.name, areaMap);
provinceMap.children.put(city.name, cityMap);
}
}
this.provinceMap = map;
} catch (IOException e) {
throw new RuntimeException(e);
}
}
public record Area(String code, String name) {
}
public record City(String code, String name, List<Area> children) {
}
public record CityMapper(String code, String name, Map<String, Area> children) {
}
public record Province(String code, String name, List<City> children) {
}
public record ProvinceMapper(String code, String name, Map<String, CityMapper> children) {
}
}
让后就是提供查询服务,并通过SpringAI 的 @Tool 注解来声明为大模型的回调工具
// 同样在 AddressAdCodeService.java 类中
/**
* 查询地址对应的行政编码
*
* @param province 省
* @param city 市
* @param area 区
* @return 行政编码
*/
@Tool(description = "传入地址信息,返回对应的行政区域编码, 如输入 湖北省武汉市武昌区,返回的行政编码为 420106")
public String queryAdCode(
@ToolParam(description = "省,如 湖北省")
String province,
@ToolParam(description = "市,如 武汉市")
String city,
@ToolParam(description = "区,如 武昌区")
String area) {
log.info("queryAdCode: {}, {}, {}", province, city, area);
ProvinceMapper provinceMap = this.provinceMap.get(province);
if (StringUtils.isBlank(city)) {
return provinceMap.code;
}
CityMapper cityMap = provinceMap.children.get(city);
if (cityMap == null) {
// 市未查到,返回省的行政编码
return provinceMap.code;
}
if (StringUtils.isBlank(area)) {
return cityMap.code;
}
Area ar = cityMap.children.get(area);
if (ar == null) {
// 区未查到,返回市的编码
return cityMap.code;
}
return ar.code;
}
2.6 完全体智能地址提取
上面虽然实现了行政区域查询服务,但是还需要把它提供给大模型使用,这一步我们需要怎么做呢?
看下面的实现,你会发现这个改动非常简单,只需两步:
- 调整提示词约束:要求大模型必须通过提供的工具来获取行政区域编码
- 注册工具
@RestController
public class ChatController {
// 注意下面提取规则中的第四点,就是我们新增的约束
private static final String SYSTEM_PROMPT = """
你是一个专业的地址信息提取专家。请从用户输入的自然语言文本中提取结构化地址信息。
提取规则:
1. 识别并分离出:省份、城市、区县、街道、详细地址
2. 地址组件可能存在简称、别称,请转换为标准名称
3. 如果用户输入包含"省"、"市"、"区"、"县"等关键词,需正确处理
4. 行政区域编码必须使用提供的工具 queryAdCode 进行获取
输出格式要求:
- 省份:完整省份名称,如"广东省"
- 城市:地级市名称,直辖市填"北京市"等
- 区县:区或县级市名称
- 街道:街道、乡镇名称
- 详细地址:门牌号、小区、楼栋等
- 行政区域编码:通常是区县一级的编码,6位数字
示例输入:"礼盒20个吉林省长春市朝阳区开运街领秀朝阳小区11栋2号楼304 田甜 18692093383"
示例输出: {
"province": "吉林省",
"city": "长春市",
"area": "朝阳区",
"street": "开运街领秀朝阳小区",
"detailInfo": "11栋2号楼304",
"adCode": "220104",
"personName": "田甜",
"personPhone": "18692093383"
}
""";
private final ChatModel chatModel;
private final ChatClient chatClient;
private final AddressAdCodeService addressAdCodeService;
@Autowired
public ChatController(ChatModel chatModel, AddressAdCodeService addressAdCodeService) {
this.chatModel = chatModel;
this.addressAdCodeService = addressAdCodeService;
this.chatClient = ChatClient.builder(chatModel)
.defaultAdvisors(new SimpleLoggerAdvisor(ModelOptionsUtils::toJsonStringPrettyPrinter, ModelOptionsUtils::toJsonStringPrettyPrinter, 0))
.defaultSystem(SYSTEM_PROMPT)
.build();
}
@GetMapping("/ai/genAddressWithCodeTool")
public Address generateAddressWithCodeTool(String content) {
// 直接使用 tools() 来注册大模型回调的工具
ChatClient.CallResponseSpec res = chatClient.prompt(content).tools(addressAdCodeService).call();
Address address = res.entity(Address.class);
return address;
}
}
再次试验一下实际表现
2.7 整体流程
接下来我们从整体的视角回归一下这个智能地址提取的全流程,其中相关的技术点为
- 提示词工程
- 结构化返回
- Function Call工具调用
三、生产优化及部署
3.1 调参:稳定性输出
在实际的生产体验过程中,我们大概率会发现,同样一段自然语言,大模型提取的详细地址这块,可能并不总是相同的,why?
对于省市区这三个相对来说比较固定的地址信息,对于街道门牌号的可能性实在是太多,大模型中有一个参数 temperature 它会控制大模型预测结果的倾向,对此有兴趣的小伙伴可以参照 大模型应用开发系列教程:第二章 模型不是重点,参数才是你真正的控制面板
在我们这个场景下,很明显不希望大模型自由发挥,所以我们可以将temperature设置较低,确保输出结果稳定
我们需要修改的就是配置文件中的spring.ai.zhipuai.chat.options.temperature,如下
spring:
ai:
zhipuai:
# api-key 使用你自己申请的进行替换;如果为了安全考虑,可以通过启动参数进行设置
api-key: ${zhipuai-api-key}
chat:
options:
model: GLM-4-Flash
temperature: 0.2
3.2 Docker容器化部署
# Dockerfile
FROM openjdk:17-jdk-slim
WORKDIR /app
COPY target/D03-text-address-extraction-0.0.1-SNAPSHOT.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar",
"--spring.ai.zhipuai.api-key=${ZHIPU_KEY}"]
3.3 Kubernetes部署配置
# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: address-agent
spec:
replicas: 3
template:
spec:
containers:
- name: address-agent
image: address-agent:1.0.0
env:
- name: OPENAI_KEY
valueFrom:
secretKeyRef:
name: ai-secrets
key: api-key
resources:
requests:
memory: "512Mi"
cpu: "250m"
limits:
memory: "1Gi"
cpu: "500m"
readinessProbe:
httpGet:
path: /actuator/health
port: 8080
四、小结
4.1 智能体 or NO?
从上到下顺下来,我们的智能地址提取基本已经完成,从本质上来说,这是一个大模型的能力包装服务,那么它算“智能体”吗?
好像也不太算~,哪个智能体这么简单呢~🤣
但若从下面这些角度出发,它又算是一个非常垂直的工具增强型智能体:
- 目标导向:用户输入的是自然语言目标——“从这段文字里提取地址并查清楚它的行政区划编码”,而不是“先调用NLP模型解析,再用结果里的省市去查XX API”。
- 自主规划与推理:系统内部自动完成了规划:① 识别出这是地址提取任务 -> ② 调用LLM进行结构化解析 -> ③ 判断是否需要并请求行政编码 -> ④ 调用外部函数获取编码 -> ⑤ 整合结果。这个决策链条是智能体自主生成的。
- 核心工具使用:其核心能力
Function Calling正是智能体的标志性特征。LLM作为“大脑”,决定使用queryAdCode这个“工具”(手或脚)。 - 可扩展的交互与记忆:虽然当前版本可能是单次查询,但它很容易扩展为多轮对话。例如,用户说“地址不对,我指的是杭州的西湖区”,智能体可以记住上下文,重新查询。
对比一个非智能体的传统方案:
一个传统的地址解析服务可能提供一个复杂的API表单,要求用户自己先分词,然后分别填写province、city、district等字段来查询编码。
用户承担了所有的规划和推理工作;而这个智能地址提取,就很“智能”了
4.2 特点
最后针对本文的内容,整体小结一下:通过本文介绍的方案,我们构建了一个端到端的智能地址解析系统,具备以下特点:
- 自然语言理解:利用大模型处理各种自由格式的地址输入
- 提示词模板:通过构建结构化的提示词管理,提高大模型的返回质量
- 结构化输出:确保输出符合标准化要求
- 实时编码查询:通过function call动态获取行政区域编码
本文对应的项目源码,可以到 spring-ai-demo 获取
4.3 未来扩展方向:
- 多模型支持:集成本地化大模型降低API成本
- 地址补全与纠错:对不完整或错误地址进行智能修正
- 国际化支持:处理多语言地址解析
- GIS集成:结合地理信息系统提供坐标映射
- 实时更新:自动同步最新的行政区划变更
通过一个简单的场景,不到200行代码,实现一个“地址提取智能体”,给大家提供一个关于大模型应用开发的案例;当然本篇内容也是作为理论科普系列教程的实战篇,强烈建议对大模型应用开发感兴趣的小伙伴,看看以下几篇内容(每篇耗时不超过五分钟😊)
回复