Army 进阶:DML 操作体系与 Spring AI 集成

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 子句阶段,再进入 _SingleUpdateClauseupdate(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 指定,冲突后更新 contentmetadataembedding 三个字段为 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 自带的 MessageWindowChatMemoryadd() 时会先删除旧消息再插入新消息。ArmyMessageChatMemoryadd() 方法不删除任何旧消息,只是追加。以下代码直接 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 枚举值重建对应的 UserMessageSystemMessageAssistantMessage(含 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_DISTANCECOSINE_DISTANCENEG_DOTSQLs 类中定义的常量。

在相似性搜索中,距离计算作为 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();

    // ... 后续为结果映射逻辑,省略

几个设计要点:

  1. 距离作为选择列distanceSelection() 将距离计算结果作为 SELECT 的一列,取别名 distanceLabel
  2. 引用选择列排序SQLs.refSelection(distanceLabel) 在 ORDER BY 中引用该别名,避免重复计算。
  3. JSONB 路径过滤Postgres.AT_AT 对应 PostgreSQL 的 @@ 操作符,JsonPathType.INSTANCE 将 JSON 路径表达式作为字面量注入,用于元数据过滤。
  4. 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,包含 conversationIdquery、可选的 similarityThresholdtopK

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 集成。几个关键设计决策:

  1. 静态方法入口 + 返回类型约束 :所有 DML 操作从 SQLs(或 PostgresMySQLs)的静态方法开始,返回类型即接口约束,编译器保证方法链的合法性。
  2. Append-Only 记忆存储ArmyMessageChatMemory 不删除旧消息,只在 get() 时限制返回数量。这与 Spring AI 自带的 MessageWindowChatMemory 的全量替换策略形成对比。
  3. Tool 调用代替 Prompt 注入ArmyMessageChatMemoryAdvisor 只保存不注入,记忆获取通过 ShortTermMemory / LongTermMemory 工具由 AI Agent 主动调用。
  4. 方言差异用 switch 显式表达 :PostgreSQL 的 ON CONFLICT DO UPDATE 和 MySQL 的 ON DUPLICATE KEY UPDATE 在同一方法中用 switch 切换,而非通过接口多态隐藏。
  5. 批量操作的阈值策略 :删除操作根据数量在 singleDelete + INbatchSingleDelete 之间切换,用 if-else 实现而非策略模式。

所有代码示例均直接 copy 自 Army 源码,未做任何修改。项目地址:github.com/PillArmy/ar...

相关推荐
吃饱了得干活1 小时前
为什么你的Service越写越臃肿?三层架构的“业务逻辑层”是个黑盒
java·后端·架构
趴下吧1 小时前
spring boot启动流程总结
java·spring boot·spring
码农进化录2 小时前
Java 程序员的 AI 进化论 | AI 帮我做 MySQL 表结构升级,三天活变半天
java·spring boot·openai
三言老师2 小时前
K8s集群运行时异常趋势分析预警实操
java·开发语言·kubernetes
吃饱了得干活2 小时前
从经典的三层架构到DDD:一次对“业务逻辑层”的解剖与重构
java·后端·架构
SL_staff2 小时前
中小离散制造如何用工艺路线模板解决‘人走流程丢’——JVS-APS 实践解析
java·设计模式·全栈
我命由我123452 小时前
Android 开发问题:使用 AndroidTreeView 时,自定义视图无法撑满父容器
android·java·java-ee·kotlin·android studio·android-studio·android runtime
冷雨夜中漫步2 小时前
DeepSeek Harness:一切皆插件的 AI Agent 框架深度解析
java·人工智能·ai·开源·github
lhldsg3 小时前
社区健身系统开发实战:从需求分析到落地部署全流程指南
java·开发语言·小程序·需求分析