FastCAE AI应用助手重构(一)架构与桥接详解

引言

FastCAE 的 AI 应用助手正在进行一次重构,目标是实现多智能体与知识图谱的接入。其核心架构为:宿主负责工程基础能力与命令执行,Python 子进程负责大模型推理与 Agent 编排,两者以一条 stdio JSONL 协议桥接,AI 通过 hostpy 反向请求真正驱动平台,危险操作再交由 HITL 人工审批。本文聚焦两件事:整体架构的层次化设计,以及 C++ 与 Python 之间的桥接关联。

一、能力与分工

FastCAE 是一个 C++/Qt 的 CAE 平台,拥有成熟的几何、网格、求解器、后处理等能力;而当下的大模型生态几乎全部生长在 Python 里(LangChain、deepagents、MCP......)。

要让 AI 真正"用"起来,只把模型接进来是不够的,模型必须能操作平台:读数据、改模型、跑求解。于是有了一个很自然的架构取舍:

**▍C++ 侧:**负责 UI、组件接入、以及"真正执行命令"的平台能力;

**▍Python 侧:**负责大模型推理、Agent 编排、工具调用、技能加载。

两者之间用一条进程间协议连接,这就是本文的主角:stdio JSONL桥接。

▲ 双引擎分工:平台能力 × 智能生态

二、整体架构

▲ FITKDeepAgent 整体架构全景

三、FastCAE 组件层:FITKDeepAgent

3.1组件接入

FITKDeepAgent 遵循 FastCAE 标准的组件工厂约定,通过 FITKComponentInterface 派生类接入:

FITKDeepAgentInterface.cpp

复制代码
QString FITKDeepAgentInterface::getComponentName()
{
    return "FITKDeepAgent";           // 组件唯一标识,工厂据此查找
}
QWidget* FITKDeepAgentInterface::getWidget(const int indexPort)
{
    Q_UNUSED(indexPort);
    return new FITKDeepAgentWindow(); // 返回组件主窗口
}

一句话:框架拿到组件名,创建主窗口,UI 就挂载进平台了。

3.2桥接客户端 FITKDeepAgentClient

这是 C++ 侧桥接的核心类,职责只有一条:把 Agent.py 子进程的 stdio 协议流,转成 Qt 信号。它定义了完整的协议语义:

协议语义速览

复制代码
请求(宿主 → agent):
    ping / chat / resume / reset / list_sessions / shutdown

事件(agent → 宿主):
    pong / start / thinking / tool_call / tool_result /
    answer / done / interrupt / error

反向请求(agent → 宿主):
    hostpy ------ agent 请求平台执行命令,
    宿主回写 hostpy_result(按 id 关联)

关键动作有三个:拉起子进程、逐行解析 stdout、按类型分发事件。

拉起子进程------用项目自带的 venv 解释器运行 Agent.py:

startAgent() · 拉起子进程

复制代码
void FITKDeepAgentClient::startAgent()
{
    QString pythonExe, agentScript;
    if (!this->locateRuntime(pythonExe, agentScript))
    {
        emit sigFailed(QString("FastCAEDeepAgent runtime not found: %1").arg(_agentRoot));
        return;
    }

    _process->setProgram(pythonExe);          // .venv/Scripts/python.exe
    _process->setArguments({ agentScript });   // Agent.py
    _process->setWorkingDirectory(_agentRoot); // FastCAEDeepAgent/
    
    // 强制 UTF-8,避免 Windows 控制台默认编码破坏 JSONL 协议
    QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
    env.insert("PYTHONUTF8", "1");
    _process->setProcessEnvironment(env);
    
    _process->start();
}

这里有两个容易踩的坑,代码都处理了

①工作目录必须是 agent 根目录,否则 .env/Agent.ini、mcp_servers/、skills/ 这些相对路径都解析不到;

②PYTHONUTF8=1 强制解释器以 UTF-8 输出,Windows 控制台默认 GBK 会把 JSON 里的中文搞乱。

逐行解析 stdout------一行一个 JSON,非法行直接丢弃:

slotReadStdout() · 逐行解析

复制代码
void FITKDeepAgentClient::slotReadStdout()
{
    _process->setReadChannel(QProcess::StandardOutput);
    while (_process->canReadLine())
    {
        const QByteArray raw = _process->readLine();
        const QString line = QString::fromUtf8(raw).trimmed();
        if (line.isEmpty()) continue;
        
        QJsonParseError parseError;
        QJsonDocument doc = QJsonDocument::fromJson(line.toUtf8(), &parseError);
        if (parseError.error != QJsonParseError::NoError || !doc.isObject()) continue;
        
        this->dispatch(doc.object());   // 合法事件交给分发器
    }
}

按类型分发------事件类型映射到 Qt 信号,UI 层只跟信号打交道:

dispatch() · 事件分发

复制代码
void FITKDeepAgentClient::dispatch(const QJsonObject& event)
{
    const QString type = event.value("type").toString();
    if (type == "hostpy")          this->handleHostPyRequest(event);
    else if (type == "pong")       emit sigConnected();
    else if (type == "thinking")   emit sigThinking(event.value("content").toString());
    else if (type == "tool_call")  emit sigToolCall(event.value("name").toString(),
                                                    event.value("args").toObject());
    else if (type == "tool_result") emit sigToolResult(event.value("name").toString(),
                                                       event.value("content").toVariant().toString());
    else if (type == "answer")     emit sigAnswer(event.value("content").toString());
    else if (type == "done")       emit sigDone();
    else if (type == "interrupt")  emit sigInterrupt(event.value("session_id").toString(),
                                                     event.value("request").toObject());
    else if (type == "error")      emit sigFailed(event.value("error").toString());
}

四、Python 智能体框架:FastCAEDeepAgent

Python 侧是一个基于 deepagents 0.7.13 的轻量框架,包在 fastcae_agent_framework/ 里。它被设计成一条流水线:把三类目录来源(LLM 配置、MCP 服务器、skills)组装成一个 deep agent。

4.1入口 Agent.py

Agent.py 是整个 stdio 服务器的入口,也是"传输与引擎解耦"的体现:

Agent.py · stdio 服务器入口

复制代码
async def main() -> None:
    sink = StdioSink()                  # 先抓原始 stdout 的二进制 buffer
    sys.stdout = sys.stderr             # 之后所有 print 诊断都去 stderr
    agent = FastCAEAgent(
        config_ini=_ROOT / ".env" / "Agent.ini",
        llm_ini=_ROOT / ".env" / "LLM.ini",
        sink=sink,
        extra_tools=[build_host_tool(sink)],   # 宿主桥接工具:平台内执行命令
    )
    await agent.ready()
    await StdioTransport(agent).run()

注意那两行关于 stdout 的处理,它是整个协议干净的关键:stdout 只承载协议 JSONL,诊断信息全部重定向到 stderr。这样宿主(C++)读取 stdout 时,永远不会被 print 日志污染。

4.2框架门面 FastCAEAgent

FastCAEAgent 是门面类,ready() 里完成组装:

FastCAEAgent.ready() · 组装流水线

复制代码
ready(self) -> "FastCAEAgent":
    llm = LLMProxy(self.llm_ini).build(self.config.llm_section)   # 1. 模型
    
    tools = await MCPProxy(self.config.mcp_dir).load_tools()      # 2. MCP 工具
    if self.extra_tools:
        tools = tools + self.extra_tools                          # 3. 宿主桥接工具
    
    skills = SkillsProxy(self.config.skills_dir, self.config.backend_root)
    
    interrupt_on = {name: True for name in self.config.hitl_tools} # 4. HITL 白名单
    
    middleware = [EventMiddleware(self.sink)] if self.sink else [] # 5. 事件中间件
    
    self.agent = create_deep_agent(
        model=llm,
        system_prompt=self.config.system_prompt,
        tools=tools,
        middleware=middleware,
        checkpointer=self.checkpointer,   # InMemorySaver:会话历史存进程内存
        backend=skills.backend,
        skills=skills.source_paths(),
        interrupt_on=interrupt_on or None,
    )
    return self

一个值得点出的设计:EventSink 是框架层唯一的抽象点。框架只负责把事件 emit 出来,具体怎么序列化、发给谁,完全由传输层决定。换 http、换 websocket,框架一行都不用改,只要再写一个 sink。

五、桥接核心(一):stdio JSONL 协议

5.1协议约定

双向都是"一行一个 JSON",UTF-8 编码。请求(宿主 → agent)共有六种:

|---------------|-----------------------|----------------|
| type | 字段 | 说明 |
| ping | --- | 健康检查,回 pong |
| chat | session_id, message | 发起一轮对话 |
| resume | session_id, decisions | HITL 中断后回传人工决定 |
| reset | session_id | 清除指定会话历史 |
| list_sessions | --- | 列出当前会话 |
| shutdown | --- | 优雅退出 |

事件(agent → 宿主):start / thinking / tool_call / tool_result / answer / interrupt / done / error,每条带 timestamp 与 session_id(非空时)。

5.2Python 侧:StdioSink

StdioSink 实现 EventSink 协议,把事件编成一行 JSON 写 stdout。它同时承担反向请求的发送与同步:

StdioSink · 事件写出与反向请求同步

复制代码
class StdioSink:
    def emit(self, event: dict) -> None:
        with self._lock:
            event = {"timestamp": _now_iso(), **event}
            line = json.dumps(event, ensure_ascii=False) + "\n"
            self._out.write(line.encode("utf-8"))
            self._out.flush()
    
    def request(self, payload: dict, timeout=300.0) -> dict:
        """向宿主发起一次反向请求并阻塞等待应答(供工具线程调用)。"""
        with self._lock:
            self._reqSeq += 1
            req_id = self._reqSeq
            line = json.dumps({"id": req_id, **payload}, ensure_ascii=False) + "\n"
            self._out.write(line.encode("utf-8"))
            self._out.flush()
            done = threading.Event()
            holder = [None]
            self._pending[req_id] = (done, holder)
        
        if not done.wait(timeout):
            return {"ok": False, "error": f"host request timeout after {timeout}s"}
        return holder[0]
    
    def resolve(self, response: dict) -> bool:
        """读线程收到宿主应答时调用:按 id 唤醒等待者。"""
        req_id = response.get("id")
        with self._lock:
            slot = self._pending.pop(req_id, None)
        if slot is None:
            return False
        slot[1][0] = response
        slot[0].set()
        return True

这里体现了一个精巧的并发模型:

▍emit: 可能来自事件循环 (chat 事件流)和工具线程(hostpy 反向请求)两个线程,内部用互斥锁保护;

▍ 反向请求用 id 关联 + threading.Event 做同步:发出请求后阻塞等待,收到 hostpy_result 时按 id 唤醒对应的等待线程。

5.3Python 侧:StdioTransport

StdioTransport 是请求循环。关键设计:stdin 读取放在独立 OS 线程,chat / resume 经 run_coroutine_threadsafe 投回事件循环执行:

StdioTransport._dispatch() · 请求循环

复制代码
def _dispatch(self, loop, sink, req) -> bool:
    type_ = req.get("type")
    if type_ == "hostpy_result":
        sink.resolve(req)                    # 反向应答:唤醒等待的工具线程
    elif type_ == "ping":
        sink.emit({"type": "pong", "ok": True})
    elif type_ == "chat":
        sid = req.get("session_id") or "默认"
        future = asyncio.run_coroutine_threadsafe(
            self._run_turn(sid, req.get("message") or "", chat=True), loop)
        future.add_done_callback(self._swallow_turn_error)
    elif type_ == "resume":
        sid = req.get("session_id") or "默认"
        future = asyncio.run_coroutine_threadsafe(
            self._run_turn(sid, req.get("decisions") or [], chat=False), loop)
        future.add_done_callback(self._swallow_turn_error)
    elif type_ == "reset":
        self.agent.sessions.delete(req.get("session_id"))
        sink.emit({"type": "reset", "ok": True, "session_id": req.get("session_id")})
    elif type_ == "list_sessions":
        sink.emit({"type": "list_sessions", "sessions": self.agent.sessions.list_ids()})
    elif type_ == "shutdown":
        sink.emit({"type": "shutdown", "ok": True})
        return True
    else:
        sink.emit({"type": "error", "ok": False, "error": f"未知请求类型: {type_}"})
    return False

为什么要这么绕?因为 chat 是 async 调用(MCP 会话等资源绑定在事件循环上),而 stdin 读线程不能被它阻塞------否则 chat 执行期间,hostpy_result 反向应答就没法送达,会形成双向请求的死锁。把读线程独立出来、用 run_coroutine_threadsafe 投回主循环,就同时保住了"不被阻塞"和"反向应答可达"两件事。

六、桥接核心(二):hostpy 反向请求,让 AI 驱动平台

▲ hostpy 反向请求完整链路

6.1Python 侧:把'执行命令'封装成工具

hostbridge.py 用 LangChain 的 @tool 装饰器,把一个"在平台内执行 Python 命令"的能力封装成工具:

hostbridge.py · 宿主桥接工具

复制代码
def build_host_tool(sink, timeout=300.0):
    @tool
    def submit_python_command(command: str) -> str:
        """Submit one Python command line to the FastCAE host platform for execution.
        
        Use this tool to run a single Python statement inside the FastCAE
        application's embedded interpreter, e.g. to drive the platform's
        data model or API. Returns the evaluated result text on success, or
        an error message on failure. Submit one statement per call and call
        the tool repeatedly for multi-step scripts.
        """
        response = sink.request({"type": "hostpy", "command": command}, timeout=timeout)
        if response.get("ok"):
            return response.get("result") or "OK"
        return f"ERROR: {response.get('error')}"
    
    return submit_python_command

大模型在对话中会自主决定调用这个工具------比如用户说"把当前网格的单元数统计一下",模型就生成一条 Python 语句,交给宿主执行,再把结果写回对话。

6.2C++ 侧:接收反向请求并执行

C++ 端收到 hostpy 事件后,走 handleHostPyRequest:

handleHostPyRequest() · 接收反向请求

复制代码
void FITKDeepAgentClient::handleHostPyRequest(const QJsonObject& request)
{
    const QString command = request.value("command").toString();
    const int requestId   = request.value("id").toInt();
    
    // 命令经共用执行器提交宿主内嵌解释器
    QString errorInfo, result;
    const bool ok = FITKDeepAgentCommandExecutor::executeCommandFromText(
        command, errorInfo, &result);
    
    // 按 id 关联回写应答:成功携带 result,失败携带 error
    QJsonObject response;
    response.insert("type", "hostpy_result");
    response.insert("id", requestId);
    response.insert("ok", ok);
    if (ok) response.insert("result", result);
    else    response.insert("error", errorInfo);
    this->sendJson(response);
    
    emit sigHostCommand(command, ok, ok ? result : errorInfo);
}

6.3真正的执行者:命令执行器

FITKDeepAgentCommandExecutor 把一行命令提交到宿主内嵌解释器。它有一个很有意思的预处理技巧------把赋值语句转成海象表达式,让 a = b 也能作为表达式求值并返回结果:executeCommandFromText() · 命令执行器

复制代码
bool FITKDeepAgentCommandExecutor::executeCommandFromText(
    const QString& commandText, QString& errorInfo, QString* result)
{
    QString statement = commandText.trimmed();
    if (statement.isEmpty()) return true;   // 空命令视为成功
    
    // 兼容赋值语句:"a = b" → "(a := b)",让赋值也能作为表达式求值返回结果
    static const QRegularExpression assignmentPattern(
        "^([A-Za-z_][A-Za-z0-9_]*)\\s*=\\s*(.+)$");
    const QRegularExpressionMatch m = assignmentPattern.match(statement);
    if (m.hasMatch())
        statement = QString("(%1 := %2)").arg(m.captured(1), m.captured(2).trimmed());
    else if (statement.contains(":=")
             && !(statement.startsWith('(') && statement.endsWith(')')))
        statement = QString("(%1)").arg(statement);
    
    // 提交宿主内嵌解释器(FITKPython,基于 PythonQt)
    Python::FITKPythonInterface* py = Python::FITKPythonInterface::getInstance();
    if (py == nullptr) { errorInfo = QObject::tr("FITKPythonInterface is null."); return false; }
    QVariant pyResult;
    if (!py->submit(statement, pyResult))
    {
        errorInfo = py->getErrorInfo();      // 优先返回解释器给出的错误
        return false;
    }
    
    if (result != nullptr && pyResult.isValid() && !pyResult.isNull())
        *result = pyResult.toString();
    return true;
}

关键在于:FITKPythonInterface 是宿主进程内嵌的解释器,它能直接访问平台注册进去的 C++ 对象和数据模型------这跟 Agent.py 子进程里的那个 venv 解释器是两码事。子进程里的 Python 没有平台上下文,只有宿主解释器能"够到"平台内部。hostpy 反向请求的意义,就是把大模型的意图,安全地送进这个有权力的解释器里执行。

补充:类注释里提到,FITKDeepAgentCommandExecutor 与 fastcaehost 原生模块共用同一实现,保证两条入口(stdio 桥接、HTTP 桥接)行为一致。

七、HITL:把'危险操作'交给人来拍板

▲ HITL 人工审批流程

在 .env/Agent.ini 里,hitl tools 白名单指定需要审批的工具:

Agent.ini · HITL 白名单

复制代码
[hitl]
; 逗号分隔,这些工具执行前需人工审批;留空则关闭 HITL
tools = submit_python_command

可以看到,宿主桥接工具 submit_python_command 默认就在审批名单里------因为让 AI 直接改平台数据是有风险的,必须人工确认。

流程是这样的:

  • 大模型准备调用 submit_python_command,因为它命中 interrupt_on,deepagents 会中断而不是执行;
  • FastCAEAgent._run 检测到中断,emit 一条 interrupt 事件(携带 action_requests 和 review_configs);
  • C++ 端 dispatch 收到 interrupt,弹出 FITKDeepAgentHitlDialog 审批框;
  • 用户对每个待执行动作选择 approve / edit / reject / respond;
  • 决定转成 decisions 数组,经 resume 请求回传,agent 恢复执行。

C++ 窗口里对应的槽函数(节选):

slotOnInterrupt() · 审批入口

复制代码
void FITKDeepAgentWindow::slotOnInterrupt(const QString& sessionId, const QJsonObject& request)
{
    // 弹出审批对话框收集决定,取消时全部拒绝,最后 resume 回传
    FITKDeepAgentHitlDialog dlg(request, this);
    const QJsonArray decisions = (dlg.exec() == QDialog::Accepted)
        ? dlg.getDecisions()
        : /* 取消 → 全部拒绝 */;
    _client->resume(sessionId, decisions);
}

八、总结

回顾一下这套设计值得称道的几个点:

▍ **进程边界即信任边界。**大模型推理跑在独立 Python 子进程里,平台核心跑在 C++ 里,中间只隔一条可审计的 JSONL 协议流------AI 想动平台,只能走 hostpy 这条被审批(HITL)管住的路。

▍ **传输与引擎解耦。**EventSink 是框架唯一的抽象点,Agent.py / stdio_transport.py 只是 stdio 这一种传输的应用层示例。将来换 http/websocket,框架一行不改。

▍ **一条协议流,双向请求。**stdout 既承载 agent 的事件,也承载 agent 的反向请求(hostpy),用 id 关联 + threading.Event 优雅地解决了跨进程双向同步,同时用独立读线程避免了双向请求死锁。

▍ **两条入口行为一致。**命令执行器 FITKDeepAgentCommandExecutor 被 stdio 桥接与 HTTP 桥接共用,平台能力只写一份。

从组件接入(FITKDeepAgentInterface)到 UI(FITKDeepAgentWindow)、桥接(FITKDeepAgentClient)、执行(FITKDeepAgentCommandExecutor),再到 Python 侧的框架流水线与 stdio 传输,整套"AI 应用助手"以一条清晰的协议缝合成一个整体:大模型负责思考,平台负责执行,人负责拍板。

相关推荐
七牛云行业应用1 小时前
爆火OpenFilm 0.2.6:开源 AI 视频 Agent 的安装、编辑与渲染流程(2026 年 10 月 )
人工智能·开源·音视频
咕泡科技1 小时前
AI前沿速递:Gemini双连发+混元双档落地!智元发布首个机器人工时KPI,具身智能分出工厂与客厅两条赛道
人工智能·机器人·腾讯混元·智元机器人·gemini4carbon·巴布机器人
whyutianict_vv1 小时前
HCIE-AI Developer 备考投入拆解:资源规划、判断矩阵与准备流程
人工智能·ai·开发
开源推荐官1 小时前
多门店 O2O 商城选型复盘:从开发视角对比主流开源系统
java·架构·开源·php
lucas_AI1 小时前
1000 美元、12 小时、零人工:开源模型 GLM 5.3 把 Firefox 的「补丁窗口」打成了自动化通道
人工智能
吴名氏.1 小时前
Java转AI Agent教程(第二周)超时重试与错误分类
java·人工智能·python·分类·ai agent
Hommy881 小时前
【直播切片工作台】第23章:媒体探测与转码:ffprobe 与 ffmpeg 封装
人工智能·ffmpeg·直播切片
Leo.yuan1 小时前
企业业务人员如何独立完成数据分析?2026年自助BI工具选型与应用指南
大数据·数据库·人工智能
俊哥V2 小时前
每日 AI 研究简报 · 2026-10-30
人工智能·ai