Army 进阶:DML 操作体系与 Spring AI 集成
在上一篇文章中,我们介绍了 Army 的编译时元模型、Criteria API 基础查询、三层类型系统以及 Session 与事务管理。本文作为续篇,将深入 Army 的 DML(INSERT/UPDATE/DELETE)操作体系,并重点剖析 Army 如何通过 Criteria API 实现 Spring AI 的 ChatMemory 和 VectorStore 接口------这是 Army 区别于其他 SQL DSL 框架的一个重要特性。
一、DML 入口:SQLs 静态方法体系
上一篇文章聚焦于 SQLs.query() 构建的 SELECT 语句。实际上,SQLs 类提供了完整的 DML 入口,全部以静态方法形式暴露:
| 方法 | 返回类型 | 用途 |
|---|---|---|
singleInsert() |
StandardInsert._PrimaryOptionSpec<Insert> |
单行/多行 INSERT |
singleUpdate() |
StandardUpdate._WithSpec<Update> |
单表 UPDATE(支持 WITH 子句) |
domainUpdate() |
StandardUpdate._DomainUpdateClause<Update> |
基于域对象的 UPDATE |
singleDelete() |
StandardDelete._WithSpec<Delete> |
单表 DELETE(支持 WITH 子句) |
domainDelete() |
StandardDelete._DomainDeleteClause<Delete> |
基于域对象的 DELETE |
batchSingleUpdate() |
StandardUpdate._WithSpec<_BatchUpdateParamSpec> |
批量单表 UPDATE |
batchSingleDelete() |
StandardDelete._WithSpec<_BatchDeleteParamSpec> |
批量单表 DELETE |
batchDomainUpdate() |
StandardUpdate._DomainUpdateClause<_BatchUpdateParamSpec> |
批量域对象 UPDATE |
batchDomainDelete() |
StandardDelete._DomainDeleteClause<_BatchDeleteParamSpec> |
批量域对象 DELETE |
subQuery() |
StandardQuery.WithSpec<SubQuery> |
子查询 |
scalarSubQuery() |
StandardQuery.WithSpec<Expression> |
标量子查询 |
这些方法的签名设计遵循一个原则:返回类型即接口约束 。例如 singleInsert() 返回 _PrimaryOptionSpec<Insert>,该接口只暴露 insertInto() 方法,编译器确保你必须先指定表才能继续后续操作。batchSingleUpdate() 返回 _WithSpec<_BatchUpdateParamSpec>,其中 _BatchUpdateParamSpec 接口额外提供了 namedParamList() 方法用于绑定批量参数------这个方法在非批量接口上不存在,编译期即可防止误用。
1.1 INSERT:从单行到多行
以下代码直接 copy 自 ArmyChatMemorySupport.saveMessages()。该类的核心字段见第三节开头的字段速查表。
ini
final void saveMessages(SyncSession session, String conversationId, List<Message> messages) {
final int size = messages.size();
if (size == 0) {
return;
}
final List<T> domainList;
domainList = createDomainList(conversationId, messages);
final Insert insertStmt;
insertStmt = SQLs.singleInsert()
.nullMode(this.nullMode)
.literalMode(this.literalMode)
.insertInto(this.tableMeta)
.values(domainList)
.asInsert();
session.update(insertStmt);
}
values() 接受域对象列表,框架内部通过 ObjectAccessorFactory 反射读取字段值。这与 MyBatis 需要手写 INSERT INTO ... VALUES (#{field}) 形成对比------Army 的列名和值的对应关系由编译时生成的元模型保证,不需要手写映射。
1.2 域对象 UPDATE/DELETE
domainUpdate() 和 domainDelete() 是 Army 的特色方法。以下代码直接 copy 自 DomainUpdateTests.updateParent():
ini
@Test(invocationCount = 3) // because first execution time contain class loading time and class initialization time
public void updateParent(final SyncLocalSession session) {
final List<ChinaRegion<?>> regionList = createReginListWithCount(3);
session.batchSave(regionList);
final BigDecimal gdpAmount = new BigDecimal("88888.66");
final LocalDateTime now = LocalDateTime.now();
final long startNanoSecond = System.nanoTime();
final Update stmt;
stmt = SQLs.domainUpdate()
.update(ChinaRegion_.T, "c")
.set(ChinaRegion_.regionGdp, SQLs::plusEqual, SQLs::param, gdpAmount)
.where(ChinaRegion_.id.in(SQLs::rowParam, extractRegionIdList(regionList)))
.and(ChinaRegion_.createTime.between(SQLs::param, now.minusMinutes(10), AND, now))
.and(ChinaRegion_.regionGdp.plus(SQLs::param, gdpAmount).greaterEqual(0))
.asUpdate();
statementCostTimeLog(session, LOG, startNanoSecond);
final long rows;
rows = session.update(stmt);
Assert.assertEquals(rows, regionList.size());
}
domainDelete() 的用法类似,以下代码直接 copy 自 DomainDeleteTests.deleteParent():
ini
@Test
public void deleteParent(final SyncLocalSession session) {
final List<ChinaRegion<?>> regionList = createReginListWithCount(3);
session.batchSave(regionList);
final LocalDateTime now = LocalDateTime.now();
final Delete stmt;
stmt = SQLs.domainDelete()
.deleteFrom(ChinaRegion_.T, "c")
.where(ChinaRegion_.id.in(SQLs::rowParam, extractRegionIdList(regionList)))
.and(ChinaRegion_.createTime.between(SQLs::param, now.minusMinutes(10), AND, now.plusSeconds(1)))
.asDelete();
final long rows;
rows = session.update(stmt);
Assert.assertEquals(rows, regionList.size());
LOG.debug("session[name : {}] rows {}", session.name(), rows);
}
domainUpdate() 与 singleUpdate() 的区别在于接口结构不同:
_DomainUpdateClause:直接从update(table, AS, alias)开始,不支持WITH子句。_WithSpec:先经过 WITH 子句阶段,再进入_SingleUpdateClause的update(table, AS, alias)。
两者都需要显式指定表名和别名,区别在于是否支持 CTE(WITH 子句)。
1.3 批量操作与 namedParamList
批量操作是 Army 的一个亮点。以下代码直接 copy 自 ArmyVectorStore.doDelete()。该类的核心字段如下:
| 字段 | 类型 | 说明 |
|---|---|---|
tableMeta |
SimpleTableMeta<T> |
编译时表元模型 |
id |
PrimaryFieldMeta<T> |
主键字段 |
content |
FieldMeta<T> |
文档内容字段 |
metadata |
FieldMeta<T> |
JSONB 元数据字段 |
embedding |
FieldMeta<T> |
向量嵌入字段 |
documentId |
FieldMeta<T> |
文档 ID 字段 |
conversationId |
FieldMeta<T> |
会话 ID 字段 |
batchDeleteThreshold |
int |
批量删除阈值,默认 20 |
kotlin
@Override
public void doDelete(final List<String> idList) {
final int idCount = idList.size();
final DeleteStatement stmt;
if (idCount < this.batchDeleteThreshold) {
stmt = SQLs.singleDelete()
.deleteFrom(this.tableMeta, AS, "t")
.where(this.documentId.in(SQLs::rowParam, idList))
.asDelete();
} else {
final List<Map<String, String>> paramList = new ArrayList<>(idCount);
for (String id : idList) {
paramList.add(Map.of(this.id.fieldName(), id));
}
stmt = SQLs.batchSingleDelete()
.deleteFrom(this.tableMeta, AS, "t")
.where(this.documentId.spaceEqual(SQLs::namedParam))
.asDelete()
.namedParamList(paramList);
}
final Function<SyncSession, Void> function;
function = session -> {
if (stmt instanceof BatchDelete) {
session.batchUpdate((BatchDelete) stmt);
} else {
session.update((Delete) stmt);
}
return null;
};
final String sessionName = getClass().getName() + '.' + "deleteById";
this.sessionContext.execute(sessionName, false, function);
}
关键设计:
SQLs::namedParam是一个方法引用,框架在执行时为每个参数 生成一个占位符,然后在执行时绑定。namedParamList()是批量操作的终止方法,只在_BatchDeleteParamSpec接口上存在。- 当删除数量低于阈值
batchDeleteThreshold(默认 20)时,使用singleDelete()+in子句;超过阈值时切换到batchSingleDelete()。
1.4 子查询
subQuery() 和 scalarSubQuery() 提供子查询能力。两者返回类型不同:
subQuery()返回SubQuery类型,可用于IN子句、EXISTS子句等需要行集合的场景。scalarSubQuery()返回Expression类型,可用于SELECT列表、WHERE条件中需要标量值的场景。
两者都支持 WITH 子句(CTE),因为返回类型都从 StandardQuery.WithSpec 开始。
二、方言特定的 DML
上一篇文章展示了 PostgreSQL 的 RETURNING 和 MySQL 的 ON DUPLICATE KEY UPDATE。这里进一步看 Army 在 Spring AI 场景中如何利用方言特性。
2.1 PostgreSQL ON CONFLICT
以下代码直接 copy 自 ArmyVectorStore.postgreInsertOrUpdateStmt():
kotlin
private Insert postgreInsertOrUpdateStmt(final List<T> rowList) {
return Postgres.singleInsert()
.nullMode(this.nullMode)
.literalMode(this.literalMode)
.insertInto(this.tableMeta).as("t")
.values(rowList)
.onConflict().parens(s -> s.space(this.onConflictField))
.doUpdate()
.set(this.content, Postgres.excluded(this.content))
.set(this.metadata, Postgres.excluded(this.metadata))
.set(this.embedding, Postgres.excluded(this.embedding))
.asInsert();
}
Postgres.excluded() 对应 PostgreSQL 的 EXCLUDED 关键字,引用 INSERT 试图插入的值。冲突字段由 onConflictField 指定,冲突后更新 content、metadata、embedding 三个字段为 EXCLUDED(即 INSERT 试图插入的新值)。
2.2 MySQL ON DUPLICATE KEY UPDATE
同一逻辑在 MySQL 下的实现,直接 copy 自 ArmyVectorStore.mysqlInsertOrUpdateStmt():
kotlin
private Insert mysqlInsertOrUpdateStmt(final List<T> rowList) {
final String rowAlias = "r";
return MySQLs.singleInsert()
.nullMode(this.nullMode)
.literalMode(this.literalMode)
.insertInto(this.tableMeta)
.values(rowList)
.as(rowAlias)
.onDuplicateKey()
.update(this.content, SQLs.field(rowAlias, this.content))
.comma(this.metadata, SQLs.field(rowAlias, this.metadata))
.comma(this.embedding, SQLs.field(rowAlias, this.embedding))
.asInsert();
}
MySQL 没有 EXCLUDED 关键字,而是通过 VALUES(column) 语法引用插入值。Army 用 SQLs.field(rowAlias, field) 封装了这个引用,等价于 VALUES(r.content)。
2.3 方言切换
以下代码直接 copy 自 ArmyVectorStore.doAdd():
arduino
switch (database) {
case PostgreSQL:
session.update(postgreInsertOrUpdateStmt(rowList));
break;
case MySQL:
session.update(mysqlInsertOrUpdateStmt(rowList));
break;
default:
throw new IllegalStateException(String.format("%s is supported", database));
}
这里用 switch 而非多态------因为 SQL 构建逻辑涉及不同的 API 入口(Postgres.singleInsert() vs MySQLs.singleInsert()),且方法链的后续调用也不同(onConflict().doUpdate() vs onDuplicateKey().update())。用 switch 显式表达方言差异,比在接口里搞多态更清晰。
三、Spring AI 集成:ChatMemory
Army 的 army-spring-ai-model-chat-memory 模块实现了 Spring AI 的两个接口。该模块的核心抽象类 ArmyChatMemorySupport<T extends SpringAiChatMemory> 有以下核心字段:
| 字段 | 类型 | 说明 |
|---|---|---|
tableMeta |
SimpleTableMeta<T> |
编译时表元模型 |
id |
PrimaryFieldMeta<T> |
主键字段 |
type |
FieldMeta<T> |
消息类型字段 |
content |
FieldMeta<T> |
消息内容字段 |
specializedData |
FieldMeta<T> |
JSONB 专用数据字段(存储 ToolCalls 等) |
conversationId |
FieldMeta<T> |
会话 ID 字段 |
nullMode |
NullMode |
NULL 渲染模式 |
literalMode |
LiteralMode |
字面量渲染模式 |
sessionContext |
SyncSessionContext |
Army 会话上下文 |
rowFunc |
Function<CurrentRecord, Message> |
行到消息转换函数 |
以下两个类继承 ArmyChatMemorySupport:
| Army 实现类 | Spring AI 接口 | 定位 |
|---|---|---|
ArmyMessageChatMemory |
ChatMemory |
高层记忆,含消息窗口 |
ArmyChatMemoryRepository |
ChatMemoryRepository |
底层持久化,无窗口逻辑 |
ArmyMessageChatMemoryAdvisor |
BaseChatMemoryAdvisor |
Advisor,自动保存对话 |
3.1 ArmyMessageChatMemory:Append-Only 设计
ArmyMessageChatMemory 的核心设计决策是 Append-Only。
Spring AI 自带的 MessageWindowChatMemory 在 add() 时会先删除旧消息再插入新消息。ArmyMessageChatMemory 的 add() 方法不删除任何旧消息,只是追加。以下代码直接 copy 自 ArmyMessageChatMemory.add():
typescript
@Override
public void add(String conversationId, List<Message> messages) {
assertConversationId(conversationId);
Assert.notNull(messages, "messages cannot be null");
final Consumer<SyncSession> function;
function = session -> {
saveMessages(session, conversationId, messages);
};
final String sessionName = getClass().getName() + '.' + "add";
this.sessionContext.executeVoid(sessionName, false, function);
}
消息窗口逻辑放在 get() 方法中:查询时按 id DESC 排序,取最近 maxMessages 条。以下代码直接 copy 自 ArmyMessageChatMemory.get():
kotlin
@Override
public List<Message> get(final String conversationId) {
assertConversationId(conversationId);
final Function<SyncSession, List<Message>> callBack;
callBack = session -> {
final Select stmt;
stmt = SQLs.query()
.select(this.content, this.type, this.specializedData, this.id)
.from(this.tableMeta, AS, "t")
.where(this.conversationId.equal(conversationId))
.orderBy(this.id.desc())
.limit(this.maxMessages)
.asQuery();
return session.queryRecordList(stmt, this.rowFunc);
};
final String sessionName = getClass().getName() + '.' + "get";
return this.sessionContext.executeNotNull(sessionName, false, callBack);
}
这个设计的好处是:历史消息不丢失,可以用于审计或重新分析。代价是存储空间增长,但可以通过定期清理任务解决。
3.2 ArmyChatMemoryRepository:全量替换
ArmyChatMemoryRepository 的定位与 ArmyMessageChatMemory 不同。它实现了 ChatMemoryRepository 接口,saveAll() 方法采用 全量替换 策略------先删除该 conversationId 的所有消息,再插入新消息。
ArmyChatMemoryRepository 还提供了 findConversationIds() 方法,返回所有不同的 conversationId。以下代码直接 copy 自 ArmyChatMemoryRepository.findConversationIds():
kotlin
@Override
public List<String> findConversationIds() {
final Function<SyncSession, List<String>> callBack;
callBack = session -> {
final Select stmt;
stmt = SQLs.query()
.selectDistinct()
.space(this.conversationId)
.from(this.tableMeta, AS, "t")
.asQuery();
return session.queryList(stmt, String.class);
};
final String sessionName = getClass().getName() + '.' + "findConversationIds";
return this.sessionContext.executeNotNull(sessionName, true, callBack);
}
这个方法在 ArmyMessageChatMemory 中不存在,因为两者的使用场景不同:ChatMemoryRepository 是给 Spring AI 的 MessageWindowChatMemory 用的,需要管理会话列表;ChatMemory 是直接给业务代码用的。
3.3 消息存储:域对象 + JSONB
消息保存时,通过 ArmyChatMemorySupport.saveMessages() 构建域对象列表并执行 INSERT。以下代码直接 copy 自 ArmyChatMemorySupport.saveMessages():
ini
final void saveMessages(SyncSession session, String conversationId, List<Message> messages) {
final int size = messages.size();
if (size == 0) {
return;
}
final List<T> domainList;
domainList = createDomainList(conversationId, messages);
final Insert insertStmt;
insertStmt = SQLs.singleInsert()
.nullMode(this.nullMode)
.literalMode(this.literalMode)
.insertInto(this.tableMeta)
.values(domainList)
.asInsert();
session.update(insertStmt);
}
createDomainList() 将 Spring AI 的 Message 对象转换为 Army 域对象。对于 AssistantMessage,它的 ToolCalls 信息通过 JsonCodec 序列化为 JSONB 存储;对于 ToolResponseMessage,其 Responses 同样序列化为 JSONB。这样设计使得 AI 的工具调用历史可以完整保存和恢复。
消息读取时的反向转换在 messageReadFunc() 中实现,根据 MessageType 枚举值重建对应的 UserMessage、SystemMessage、AssistantMessage(含 ToolCalls 反序列化)。
3.4 Advisor:只保存,不注入
ArmyMessageChatMemoryAdvisor 实现了 BaseChatMemoryAdvisor 接口,其设计有一个关键决策:
只保存,不注入。 Advisor 在
before()中保存用户消息,在after()中保存 AI 回复,但不自动将历史记忆注入到 prompt 中。
以下代码直接 copy 自 ArmyMessageChatMemoryAdvisor:
ini
@Override
public ChatClientRequest before(ChatClientRequest chatClientRequest, AdvisorChain advisorChain) {
final String conversationId;
conversationId = getConversationId(chatClientRequest.context());
final Message userMessage;
userMessage = chatClientRequest.prompt().getLastUserOrToolResponseMessage();
this.chatMemory.add(conversationId, userMessage);
return chatClientRequest;
}
@Override
public ChatClientResponse after(ChatClientResponse chatClientResponse, AdvisorChain advisorChain) {
final ChatResponse response;
response = chatClientResponse.chatResponse();
if (response != null) {
final List<Message> assistantMessages;
assistantMessages = response
.getResults()
.stream()
.map(g -> (Message) g.getOutput())
.toList();
if (!assistantMessages.isEmpty()) {
this.chatMemory.add(this.getConversationId(chatClientResponse.context()), assistantMessages);
}
}
return chatClientResponse;
}
历史记忆的获取是通过 Tool 调用 完成的,而非在 prompt 中全量注入。这避免了每次请求都携带全部历史消息的问题。
四、Spring AI 集成:VectorStore
army-spring-ai-vector-store 模块的 ArmyVectorStore 继承了 Spring AI 的 AbstractObservationVectorStore,实现了 VectorStore 接口。该类的核心字段见第 1.3 节的字段速查表。
4.1 pgvector 距离算子
ArmyVectorStore 支持三种距离类型。以下代码直接 copy 自 ArmyVectorStore.DistanceType:
csharp
public enum DistanceType {
/// <-> - L2 distance
///
/// @see <a href="https://github.com/pgvector/pgvector">pgvector</a>
L2_DISTANCE(SQLs.L2_DISTANCE),
/// <=> - Cosine distance
///
/// @see <a href="https://github.com/pgvector/pgvector">pgvector</a>
COSINE_DISTANCE(SQLs.COSINE_DISTANCE),
/// (negative) inner product
NEG_DOT(SQLs.NEG_DOT);
public final SQLs.DualOperator operator;
DistanceType(SQLs.DualOperator operator) {
this.operator = operator;
}
}
这三种算子对应 pgvector 的 <->、<=> 和内积操作。SQLs.DualOperator 是 Army 的双操作符接口,L2_DISTANCE、COSINE_DISTANCE、NEG_DOT 是 SQLs 类中定义的常量。
在相似性搜索中,距离计算作为 SQL 表达式的一部分嵌入查询。以下代码直接 copy 自 ArmyVectorStore.distanceSelection():
kotlin
private Selection distanceSelection(float[] queryEmbedding, String label) {
final Selection selection;
if (this.operator == SQLs.NEG_DOT) {
selection = SQLs.LITERAL_1.plus(this.embedding.space(this.operator, queryEmbedding)).as(label);
} else {
selection = this.embedding.space(this.operator, queryEmbedding).as(label);
}
return selection;
}
NEG_DOT 的处理多了一步 1 + embedding <operator> query,因为 pgvector 的内积操作返回负值,需要偏移为正数以便排序。这个细节在源码注释中没有解释,但从数学逻辑上可以推断:负内积的绝对值越小(越接近 0),表示向量越相似。
4.2 相似性搜索
以下代码直接 copy 自 ArmyVectorStore.doSimilaritySearch():
ini
@Override
public List<Document> doSimilaritySearch(final SearchRequest request) {
final Filter.Expression expression = request.getFilterExpression();
final IPredicate conversationIdPredicate;
final String jsonPath;
if (expression == null) {
jsonPath = null;
conversationIdPredicate = null;
} else {
jsonPath = this.converter.convertExpression(expression);
if (this.chatStore) {
conversationIdPredicate = parseFirstLevelConversationIdPredicate(expression);
} else {
conversationIdPredicate = null;
}
}
final double distance = 1 - request.getSimilarityThreshold();
final float[] queryEmbedding;
queryEmbedding = embeddingText(request.getQuery());
final String distanceLabel = "distance";
final Select stmt;
stmt = SQLs.query()
.select(this.documentId, this.content, this.metadata)
.comma(distanceSelection(queryEmbedding, distanceLabel))
.from(this.tableMeta, AS, "t")
.where(wb -> {
if (conversationIdPredicate != null) {
wb.accept(conversationIdPredicate);
}
wb.accept(distancePredicate(queryEmbedding, distance));
if (jsonPath != null) {
wb.accept(this.metadata.space(Postgres.AT_AT, SQLs.literal(JsonPathType.INSTANCE, jsonPath)));
}
})
.orderBy(SQLs.refSelection(distanceLabel))
.limit(request.getTopK())
.asQuery();
// ... 后续为结果映射逻辑,省略
几个设计要点:
- 距离作为选择列 :
distanceSelection()将距离计算结果作为 SELECT 的一列,取别名distanceLabel。 - 引用选择列排序 :
SQLs.refSelection(distanceLabel)在 ORDER BY 中引用该别名,避免重复计算。 - JSONB 路径过滤 :
Postgres.AT_AT对应 PostgreSQL 的@@操作符,JsonPathType.INSTANCE将 JSON 路径表达式作为字面量注入,用于元数据过滤。 - conversationId 谓词 :如果向量存储用于聊天记忆(
chatStore = true),则自动从过滤表达式中提取 conversationId,添加到 WHERE 条件中。
4.3 长期记忆工具
ArmyVectorStore 提供了 memoryTool() 方法,创建一个 ToolCallback 供 AI Agent 调用。以下代码直接 copy 自 ArmyVectorStore.memoryTool():
less
public ToolCallback memoryTool(@Nullable String name) {
if (name == null) {
name = "LongTermMemory";
}
return FunctionToolCallback
.builder(name, this::getMemoryList)
.description("Get long term memory")
.inputType(MemoryCall.class)
.build();
}
这个工具使用的是 io.army.spring.ai.vectorstore.MemoryCall,包含 conversationId、query、可选的 similarityThreshold 和 topK:
less
public record MemoryCall(
@ToolParam(description = "conversation id") String conversationId,
@ToolParam(description = "query, Retrieve texts related to long-term memory") String query,
@ToolParam(description = "similarity threshold,default 0.0", required = false) @Nullable Double similarityThreshold,
@ToolParam(description = "the top 'k' similar results to return,default 4", required = false) @Nullable Integer topK
) {
}
回调函数 getMemoryList() 执行向量相似性搜索,将查询文本通过 EmbeddingModel 转为向量,再用 pgvector 距离算子检索相似记忆。
4.4 短期记忆工具
ArmyChatMemorySupport 也提供了一个 memoryTool(),但名称是 ShortTermMemory。以下代码直接 copy 自 ArmyChatMemorySupport.memoryTool():
less
public ToolCallback memoryTool(@Nullable String name) {
if (name == null) {
name = "ShortTermMemory";
}
return FunctionToolCallback
.builder(name, this::getMemoryList)
.description("Get short term memory")
.inputType(MemoryCall.class)
.build();
}
短期记忆工具查询的是对话历史表(非向量),按 id DESC 排序取最近 N 条。以下代码直接 copy 自 ArmyChatMemorySupport.getMemoryList():
kotlin
private List<Map<String, Object>> getMemoryList(MemoryCall call) {
Objects.requireNonNull(call);
final String conversationId = call.conversationId();
assertConversationId(conversationId);
final Function<SyncSession, List<Map<String, Object>>> callBack;
callBack = session -> {
Integer maxRow;
maxRow = call.maxRow();
if (maxRow == null) {
maxRow = maxMessages();
}
final Select stmt;
stmt = SQLs.query()
.select(this.content, this.type, this.tableMeta.createTime())
.from(this.tableMeta, AS, "t")
.where(this.conversationId.equal(conversationId))
.and(this.type.in(SQLs::rowLiteral, List.of(MessageType.USER, MessageType.ASSISTANT)))
.orderBy(this.id.desc())
.ifLimit(maxRow)
.asQuery();
return session.queryObjectList(stmt, RowMaps.hashMapConstructor(3));
};
final String sessionName = getClass().getName() + '.' + "getMemoryList";
return this.sessionContext.executeNotNull(sessionName, true, callBack);
}
这里使用的是 io.army.spring.ai.chat.memory.MemoryCall(与长期记忆工具的 MemoryCall 是不同包下的同名 record),包含 conversationId 和可选的 maxRow 参数:
less
public record MemoryCall(
@ToolParam(description = "conversation id") String conversationId,
@ToolParam(description = "max row count", required = false) @Nullable Integer maxRow
) {
}
4.5 长期与短期的协作
Army 的 Spring AI 集成形成了两级记忆体系:
| 层级 | 工具名 | 数据源 | 查询方式 | 场景 |
|---|---|---|---|---|
| 短期记忆 | ShortTermMemory |
对话历史表 | id DESC + LIMIT |
获取最近 N 条对话 |
| 长期记忆 | LongTermMemory |
向量存储表 | pgvector 相似性搜索 | 检索语义相关记忆 |
AI Agent 可以同时挂载这两个工具,根据需要主动调用。例如,用户问"上次我们讨论的架构方案是什么",AI 可以调用 ShortTermMemory 获取最近对话;用户问"之前有没有类似的需求设计",AI 可以调用 LongTermMemory 做语义检索。
这种设计比传统的全量注入更高效:不是每次请求都携带全部历史,而是让 AI 根据问题自行判断是否需要检索记忆。
4.6 删除策略:单条 vs 批量
删除策略的代码已在第 1.3 节展示(ArmyVectorStore.doDelete())。当删除数量低于 batchDeleteThreshold(默认 20)时,使用 singleDelete() + IN 子句;超过阈值时切换到 batchSingleDelete(),为每个 ID 生成一条参数化 DELETE 语句。这个策略在源码中通过简单的 if-else 实现,没有过度抽象。
五、总结
本文从源码角度剖析了 Army 的 DML 操作体系和 Spring AI 集成。几个关键设计决策:
- 静态方法入口 + 返回类型约束 :所有 DML 操作从
SQLs(或Postgres、MySQLs)的静态方法开始,返回类型即接口约束,编译器保证方法链的合法性。 - Append-Only 记忆存储 :
ArmyMessageChatMemory不删除旧消息,只在get()时限制返回数量。这与 Spring AI 自带的MessageWindowChatMemory的全量替换策略形成对比。 - Tool 调用代替 Prompt 注入 :
ArmyMessageChatMemoryAdvisor只保存不注入,记忆获取通过ShortTermMemory/LongTermMemory工具由 AI Agent 主动调用。 - 方言差异用 switch 显式表达 :PostgreSQL 的
ON CONFLICT DO UPDATE和 MySQL 的ON DUPLICATE KEY UPDATE在同一方法中用 switch 切换,而非通过接口多态隐藏。 - 批量操作的阈值策略 :删除操作根据数量在
singleDelete + IN和batchSingleDelete之间切换,用 if-else 实现而非策略模式。
所有代码示例均直接 copy 自 Army 源码,未做任何修改。项目地址:github.com/PillArmy/ar...