Python与Java双栈实战:手撸一个支持RAG与Tool Calling的高性能Agent框架

Python与Java双栈实战:手撸一个支持RAG与Tool Calling的高性能Agent框架

引言

在2026年的AI工程招聘市场上,"会调LangChain"已经不够了。企业真正需要的是能够设计跨语言、高可用、可扩展Agent框架的工程师。为什么?因为生产环境从来不是单一语言的游戏。

Python在AI生态中占据统治地位,LangChain、LlamaIndex等框架让RAG与Tool Calling的快速原型开发变得轻而易举。但当系统需要接入企业级Java微服务、处理高并发请求、保障事务一致性时,纯Python方案开始力不从心。反过来,Java的Spring AI虽然提供了完整的工具调用体系,但在RAG生态的灵活性上仍不及Python。

双栈架构不是"为了双栈而双栈",而是让每种语言做它最擅长的事:Python负责AI编排与检索,Java负责业务权威状态与事务保障。本文将手把手带你用Python + Java构建一个支持RAG与Tool Calling的Agent框架,给出可运行的代码,并解析架构设计中的关键决策。

一、为什么双栈架构是Agent工程化的必然选择

先看一个真实的生产场景:用户对Agent说"帮我查一下上个月的差旅报销状态,如果超过限额就发起申诉流程"。

这个请求涉及三层能力:第一层是知识检索 ("公司差旅报销限额是多少"),第二层是业务查询 ("上个月提交了哪些报销单,状态是什么"),第三层是受控写操作("如果超限,生成申诉Proposal")。

如果全部用Python实现,你会遇到几个问题:Python服务需要直接连接企业数据库,这在安全审计上很难通过;报销状态查询需要遵循Java服务的事务隔离级别,Python重写一套容易引入不一致;最关键的是,当系统需要水平扩展时,Python的异步模型在高并发I/O密集型场景下不如Java的虚拟线程来得稳定。

参考一个成熟的开源企业级项目 enterprise-ai-copilot 的设计,其架构非常清晰:请求先进入Java ,Java负责生成可信的trace_id、employee_id等Runtime Context字段,Python服务只在Docker网络内部暴露端口,不对外映射。Java调用Python做AI编排,Python调用Java的只读业务Tool获取权威状态。

这种设计的核心理念是:Python拥有的是"思考能力",Java拥有的是"事实权威"。

二、架构分层:从MCP到Tool Registry的双栈设计

在动手写代码之前,先明确分层架构。参考AgentCraft项目的五层架构,我们简化为四层:

第一层:协议适配层。由Java的Spring Boot负责,暴露HTTP API给前端,同时提供内部RPC接口供Python调用业务Tool。这一层管理认证、限流、全链路追踪ID。

第二层:Agent编排层。由Python负责,核心是ReAct循环、意图识别、工具路由和RAG检索。这一层不碰任何业务数据库,只通过Tool调用获取事实。

第三层:Tool Registry层。这是双栈架构的"粘合层"。所有工具------无论是Python本地的RAG检索,还是通过MCP协议暴露的Java服务------都注册到统一的ToolRegistry中,使用标准化的Schema描述输入输出。

第四层:能力实现层。Python侧实现向量检索、Embedding、Rerank;Java侧实现业务逻辑、事务、审计。

这个架构的关键在于Tool Registry的抽象设计。Tool不应该关心它是用Python还是Java实现的,它只需要暴露三样东西:name、description、input_schema。

python 复制代码
# tool_registry.py
from dataclasses import dataclass, field
from typing import Callable, Any, Optional
import inspect
import json

@dataclass
class ToolSchema:
    """工具的输入/输出Schema定义"""
    type: str = "object"
    properties: dict = field(default_factory=dict)
    required: list[str] = field(default_factory=list)

@dataclass
class ToolMetadata:
    timeout_seconds: int = 30
    max_retries: int = 3
    requires_auth: bool = False

@dataclass
class Tool:
    name: str
    description: str
    input_schema: ToolSchema
    output_schema: Optional[ToolSchema] = None
    metadata: ToolMetadata = field(default_factory=ToolMetadata)
    handler: Optional[Callable] = None
    is_remote: bool = False  # True表示通过MCP/RPC调用Java服务

class ToolRegistry:
    """工具注册器(单例),统一管理本地与远程工具"""
    _instance = None
    
    def __new__(cls):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
            cls._instance._tools: dict[str, Tool] = {}
        return cls._instance
    
    def register(self, tool: Tool) -> None:
        if tool.name in self._tools:
            raise ValueError(f"Tool '{tool.name}' already registered")
        self._tools[tool.name] = tool
    
    def get_tool(self, name: str) -> Optional[Tool]:
        return self._tools.get(name)
    
    def get_all_tools(self) -> dict[str, Tool]:
        return self._tools.copy()
    
    def list_tools_for_llm(self) -> list[dict]:
        """转换为OpenAI Function Calling格式"""
        tools = []
        for tool in self._tools.values():
            tools.append({
                "type": "function",
                "function": {
                    "name": tool.name,
                    "description": tool.description,
                    "parameters": {
                        "type": tool.input_schema.type,
                        "properties": tool.input_schema.properties,
                        "required": tool.input_schema.required
                    }
                }
            })
        return tools
    
    async def invoke(self, name: str, arguments: dict) -> Any:
        """执行工具调用,带超时和重试"""
        tool = self._tools.get(name)
        if not tool:
            raise ValueError(f"Tool '{name}' not found")
        
        if tool.is_remote:
            return await self._invoke_remote(tool, arguments)
        
        # 本地工具直接调用
        if asyncio.iscoroutinefunction(tool.handler):
            return await asyncio.wait_for(
                tool.handler(**arguments),
                timeout=tool.metadata.timeout_seconds
            )
        return tool.handler(**arguments)

这段Registry代码是双栈架构的基石。is_remote标记区分了本地Python工具和远程Java服务,对Agent层完全透明。

三、Python侧:RAG与Agent编排的核心实现

3.1 可插拔的RAG检索器

RAG的核心不是"调一次向量库API",而是检索质量的可控性 。参考llm-agent-base的设计思路,我们需要支持:按相似度阈值过滤弱匹配、支持文件名关键词搜索(不依赖向量索引)、以及增量索引更新。

python 复制代码
# rag_retriever.py
from dataclasses import dataclass
from typing import Optional
import numpy as np

@dataclass
class Chunk:
    content: str
    source: str
    score: float = 0.0
    metadata: dict = None

class RAGRetriever:
    """轻量级RAG检索器,支持向量检索与关键词回退"""
    
    def __init__(self, embedding_fn, vector_store, min_score: float = 0.35):
        self.embed = embedding_fn
        self.store = vector_store
        self.min_score = min_score  # 低于此阈值的结果被丢弃
    
    async def retrieve(self, query: str, top_k: int = 5) -> list[Chunk]:
        """语义检索,带质量过滤"""
        query_vec = await self.embed(query)
        raw_results = await self.store.search(query_vec, top_k=top_k * 2)
        
        # 过滤弱匹配,避免用噪声填满top_k
        filtered = [
            Chunk(content=r["content"], source=r["source"], score=r["score"])
            for r in raw_results
            if r["score"] >= self.min_score
        ]
        
        return filtered[:top_k] if filtered else raw_results[:top_k]
    
    async def keyword_search(self, keywords: list[str], 
                              search_in: str = "both",
                              match_mode: str = "any",
                              min_matches: int = 2) -> list[Chunk]:
        """
        文件名/内容关键词搜索,无需向量索引
        适用场景:结构化文档、代码仓库检索
        """
        # 实现省略,核心逻辑:遍历文档索引,匹配文件名或内容
        pass

def format_rag_context(chunks: list[Chunk]) -> str:
    """将检索结果格式化为LLM可读的上下文"""
    if not chunks:
        return "未检索到相关文档。"
    
    parts = []
    for i, chunk in enumerate(chunks, 1):
        parts.append(f"[文档{i}] 来源: {chunk.source}\n{chunk.content}")
    
    return "\n\n---\n\n".join(parts)

min_score过滤是一个容易被忽视但至关重要的设计。很多RAG实现为了"凑够top_k",把低相关度的内容也塞进上下文,导致LLM被噪声误导。宁可返回更少的上下文,也不要引入幻觉。

3.2 ReAct Agent核心循环

现在实现Agent的执行引擎。核心是一个"推理-行动-观察"的循环,集成RAG检索和Tool Calling。

python 复制代码
# agent_engine.py
import asyncio
import json
from dataclasses import dataclass, field
from typing import Optional
import openai

@dataclass
class AgentState:
    """Agent执行状态(可序列化,支持持久化)"""
    session_id: str
    messages: list = field(default_factory=list)
    tool_calls_log: list = field(default_factory=list)
    iteration_count: int = 0

class ReActAgent:
    """ReAct范式的Agent引擎,支持RAG与Tool Calling混合"""
    
    def __init__(self, llm_client, registry, retriever, 
                 max_iterations: int = 6):
        self.llm = llm_client
        self.registry = registry
        self.retriever = retriever
        self.max_iterations = max_iterations
    
    async def run(self, user_input: str, state: AgentState) -> str:
        state.messages.append({"role": "user", "content": user_input})
        
        for iteration in range(self.max_iterations):
            state.iteration_count = iteration + 1
            
            # 决策:LLM决定是否需要RAG、是否需要调用工具
            system_prompt = self._build_system_prompt(state)
            tools = self.registry.list_tools_for_llm()
            
            response = await self.llm.chat.completions.create(
                model="gpt-4o",
                messages=[{"role": "system", "content": system_prompt}] + state.messages,
                tools=tools if tools else None,
                tool_choice="auto"
            )
            
            msg = response.choices[0].message
            
            # 情况一:无工具调用,直接返回最终答案
            if not msg.tool_calls:
                state.messages.append({"role": "assistant", "content": msg.content})
                return msg.content
            
            # 情况二:执行工具调用
            state.messages.append(msg)
            
            for tool_call in msg.tool_calls:
                tool_name = tool_call.function.name
                arguments = json.loads(tool_call.function.arguments)
                
                # 特殊处理:RAG检索作为"虚拟工具"
                if tool_name == "retrieve_knowledge":
                    result = await self._handle_rag_call(arguments)
                else:
                    try:
                        result = await self.registry.invoke(tool_name, arguments)
                    except Exception as e:
                        result = f"[工具调用失败: {str(e)}]"
                
                state.messages.append({
                    "role": "tool",
                    "tool_call_id": tool_call.id,
                    "content": str(result)
                })
                state.tool_calls_log.append({
                    "tool": tool_name,
                    "args": arguments,
                    "result_preview": str(result)[:200]
                })
        
        return "已达到最大迭代次数,任务未完成。"
    
    def _build_system_prompt(self, state: AgentState) -> str:
        return """你是一个企业级AI助手。你有以下能力:
1. 通过 retrieve_knowledge 工具检索企业知识库
2. 通过其他工具查询业务系统状态
3. 执行受控的业务操作

重要约束:
- 对于事实性问题(如公司政策、产品信息),必须先调用 retrieve_knowledge 检索
- 对于需要操作业务系统的请求,使用相应的工具
- 如果工具返回结果不充分,可以再次检索或调用其他工具
- 不要编造知识库中不存在的信息"""
    
    async def _handle_rag_call(self, arguments: dict) -> str:
        query = arguments.get("query", "")
        chunks = await self.retriever.retrieve(query)
        return format_rag_context(chunks)

这个Agent循环有几个关键设计:RAG被封装为"虚拟工具" ,与业务工具走同一套调用协议;迭代上限 防止无限循环;状态外置使Agent实例可以无状态部署。

四、Java侧:Tool Provider与MCP服务暴露

Python Agent需要调用Java的业务能力。最优雅的方式是通过MCP协议暴露Java服务,让Agent像调用本地工具一样调用远程Java方法。

参考Spring AI 2.0的MCP支持,Java端只需要一个注解就能将方法暴露为MCP Tool:

java 复制代码
// WeatherTools.java --- Java侧的业务工具
@Component
public class ExpenseTools {
    
    private final ExpenseService expenseService;
    
    @McpTool(description = "查询员工差旅报销单状态")
    public ExpenseStatus queryExpenseStatus(
            @McpToolParam(description = "员工工号") String employeeId,
            @McpToolParam(description = "月份,格式YYYY-MM") String month) {
        return expenseService.queryStatus(employeeId, month);
    }
    
    @McpTool(description = "提交差旅报销申诉,返回Proposal ID(不执行实际写操作)")
    public ProposalResult submitAppealProposal(
            @McpToolParam(description = "报销单ID") String expenseId,
            @McpToolParam(description = "申诉理由") String reason) {
        // Proposal阶段无副作用,仅生成待确认记录
        return expenseService.createProposal(expenseId, reason);
    }
}

MCP Server的自动配置会扫描@McpTool注解的方法,生成JSON Schema并注册。Python侧只需要通过MCP Client连接,就能发现并调用这些工具。

但高可用架构需要比MCP走得更远。直接让Agent调用Java MCP Server有两个问题:第一,Java服务不可用时Agent会阻塞;第二,缺少Python侧的缓存和降级。

参考enterprise-ai-copilot的设计,Python侧应该维护一个只读Tool的本地缓存层:

python 复制代码
# java_tool_client.py
import asyncio
from typing import Optional
import httpx

class JavaToolClient:
    """Python到Java业务Tool的客户端,带缓存与降级"""
    
    def __init__(self, base_url: str, internal_token: str, 
                 cache_ttl: int = 60):
        self.base_url = base_url
        self.token = internal_token
        self.cache_ttl = cache_ttl
        self._cache: dict[str, tuple[float, any]] = {}
    
    async def query_leave_balance(self, employee_id: str) -> dict:
        """查询年假余额(只读,带缓存)"""
        cache_key = f"leave_balance:{employee_id}"
        cached = self._get_cached(cache_key)
        if cached:
            return cached
        
        try:
            result = await self._call_java_api(
                "/internal/leave/balance",
                {"employee_id": employee_id}
            )
            self._set_cache(cache_key, result)
            return result
        except Exception as e:
            # 降级:返回缓存中的过期数据(如有),或明确的错误信息
            stale = self._get_cached(cache_key, ignore_ttl=True)
            if stale:
                return {**stale, "_stale": True}
            raise RuntimeError(f"业务系统暂时不可用: {e}")
    
    async def _call_java_api(self, path: str, params: dict) -> dict:
        async with httpx.AsyncClient(timeout=5.0) as client:
            resp = await client.post(
                f"{self.base_url}{path}",
                json=params,
                headers={"Authorization": f"Bearer {self.token}"}
            )
            resp.raise_for_status()
            return resp.json()

这个设计实现了优雅降级 :Java服务短暂不可用时,返回缓存中的过期数据并标记_stale,让Agent可以决定是继续等待还是告知用户"数据可能不是最新的"。

五、双栈协同的关键工程细节

Runtime Context的可信传递 。在双栈架构中,trace_id、employee_id等字段必须由Java在入口处生成并透传,不能由LLM的tool_call arguments提供。这既是安全要求(防止Prompt注入伪造身份),也是审计要求。

工具可见性的动态收缩 。Planner只有规划权,没有执行授权。allow_business_actions为false时,Proposal工具应该从工具列表中移除,而不是靠LLM"自觉地不调用"。这需要在ToolRegistry层做过滤。

Tool Calling的循环边界 。Spring AI 2.0将工具执行从ChatModel中移出,由ToolCallingAdvisor在外部控制。Python侧的ReAct循环也需要明确的迭代上限和工具调用次数上限,防止Agent陷入"检索-发现不足-再检索"的死循环。

MCP工具的名称空间隔离 。当同时连接多个Java MCP Server时,如果它们暴露了同名工具,需要前缀机制避免冲突。Python的ToolRegistry应该用{server_name}__{tool_name}作为唯一标识。

结语

Python与Java的双栈Agent框架,本质上是一种关注点分离的工程哲学:Python拥有AI编排的灵活性和生态优势,Java拥有企业级系统的可靠性、事务性和安全审计能力。

从代码层面看,核心不是"写两套语言",而是用统一的Tool Registry抽象屏蔽语言边界。Agent层只关心工具的名称、Schema和语义,不关心它是Python函数还是远程Java MCP服务。

如果你正在设计或重构企业级Agent系统,建议从这三个步骤入手:先定义ToolRegistry的接口规范 ,这是双栈架构的"宪法";再实现Java侧的MCP Tool暴露 ,让业务能力标准化输出;最后在Python侧完成ReAct循环与RAG集成,把编排逻辑跑通。这套架构的投入成本不低,但它换来的是:当业务增长十倍时,你不需要重写核心逻辑,只需要在ToolRegistry中注册新的能力。

相关推荐
谢亮_vipxieliang1 小时前
Go Context 控制协程生命周期
开发语言·golang
xuxigifxfh1 小时前
HJ4 字符串分隔
java·开发语言·华为机考
写后端的胖头鱼1 小时前
Java 反射详解 + 场景示例
java·反射
databook1 小时前
什么是范数?用 NumPy 动手算一遍就明白了
python·数学·numpy
MayZork1 小时前
Qt 开发集成CMake + vcpkg
开发语言·c++·qt
朝朝辞暮i2 小时前
VLA 系统学习第 5 课:神经网络的参数到底是什么?——从 nn.Linear 真正理解 W、 b 和 Gradient
人工智能·python·深度学习·神经网络·vla
XZ-0700012 小时前
week7-文本
python
零基础1232 小时前
PDF 处理工具全攻略:pdf24、PDFgear、Adobe Acrobat DC 与 Stirling-PDF 深度对比
java·开发语言·pdf
Java后端的Ai之路2 小时前
01-React基础教程
开发语言·前端·python·react.js·前端框架