Files
MAF1/README.md
T
admin777 b631acd42e feat: 将插件 stdin 协议定为 v1,并按声明注入凭据
进程外插件改为只读环境变量和自身配置,避免读宿主 appsettings;宿主按 plugin.json 声明注入 LLM / OpenWeather 等凭据。
2026-09-11 10:46:52 +08:00

501 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# MAF1 — 多智能体工作流编排
MAF1 是一个基于 **.NET 10** 和 **Microsoft Agents AI** 的多智能体演示仓库。同一条「读文件抽城市 → 查天气」链路拆成两个可执行项目:
1. **MAF1**:命令行工作流。用 Microsoft Agents AI Workflows 对比「条件写在节点里」和「条件写在边上」。
2. **MAF1.Route**:命令行路由。方案 C 的图编译成 `AgentWorkflowBuilder.CreateHandoffBuilderWith` + `WithHandoff`;专家仍是 Core 里的 Agent。
3. **MAF1.Web**:可视化编排器。浏览器里拖节点、连线、配条件,然后一键运行。
内置 Agent 在 `MAF1.Core` 里;网页还可以把同一套能力以 **独立进程插件** 的形式画到画布上。LLM 的 endpoint / API Key / 模型由各宿主注入,**不会**画成节点端口,也不会写进流程图 JSON。
网页打开:<http://127.0.0.1:5288>(先启动 `MAF1.Web`
---
## 功能概览
- **可视化工作流**:左侧节点面板(系统节点 + 扫描到的插件)、画布连线、右侧检查器、底部运行日志。
- **系统节点**`fileCity-system`(读文本抽城市)、`weather-system`(按城市查天气并汇总)。
- **进程外插件**:扫描输出目录 `plugins/*/plugin.json`,按 `launch` 启动子进程,stdin / stdout 走 JSON。
- **凭据隔离**:宿主配置 LLM;节点只声明需要哪种凭据(默认 `llm-default`)。浏览器拿不到原始 Key。
- **边条件**:连线可设 `hasValidCities` / `!hasValidCities`,不满足则跳过下游节点。
- **人机决策**:抽城市失败时暂停等待确认;必须有默认方案(结束查询)。网页弹出确认框,CLI 在控制台询问。超时未确认则自动采用默认方案,也可以改填其它城市继续查天气。
- **CLI 对照**`node` 模式把「有没有城市」写在 Gate 节点里;`edge` 模式把这条判断写在边上。两种模式缺城市时都会进入同一套确认。
- **天气数据源**:默认 [wttr.in](https://wttr.in),可改为 OpenWeatherMap。
---
## 技术栈
| 部分 | 说明 |
|------|------|
| 运行时 | .NET 10`net10.0` |
| 控制台 | `MAF1`:工作流;`MAF1.Route`:方案 C 路由 |
| 网页 | `MAF1.Web`ASP.NET Core Minimal API + `wwwroot` |
| LLM | `Azure.AI.OpenAI` + `Microsoft.Agents.AI.OpenAI`(兼容 OpenAI / Azure OpenAI / DeepSeek 等) |
| 前端 | `MAF1.Web/wwwroot` 下原生 HTML / CSS / JS,无 npm 依赖 |
| 插件 | 独立控制台进程,协议见 `MAF1.Core/PluginContract`,由网页宿主扫描 |
解决方案文件:`MAF1.slnx``MAF1``MAF1.Route``MAF1.Web``MAF1.Core`、两个插件工程)。
---
## 仓库结构
```
仓库根目录/
├── MAF1.slnx
├── MAF1/ # 控制台项目
│ ├── MAF1.csproj
│ ├── Program.cs / CliHost.cs
│ ├── Workflows/ # Microsoft Agents AI Workflow
│ ├── Orchestration/ # 控制台事件流 + 确认提示
│ ├── Data/
│ └── appsettings.json
├── MAF1.Web/ # 网页编排项目
│ ├── MAF1.Web.csproj
│ ├── Program.cs # Minimal API
│ ├── Web/ # 图执行、运行时组装
│ ├── PluginHost/ # 扫描插件、起进程、注入凭据
│ ├── wwwroot/ # 可视化界面
│ ├── Data/
│ └── appsettings.json
├── MAF1.Route/ # 控制台路由(方案 C)
│ ├── Graphs/ # 白名单图 JSON(有边 / 无边)
│ ├── Data/
│ └── appsettings.json
├── MAF1.Core/ # 共享:Agent、Tool、LLM、插件协议
└── plugins/
├── file-city/
└── weather/
```
构建 **MAF1.Web** 时会编译两个插件,并把输出复制到网页宿主 `bin/.../plugins/`。**运行时扫描的是网页 exe 旁边的 `plugins/`,不是源码树。** 控制台项目不扫描插件。
---
## 环境要求
- [.NET 10 SDK](https://dotnet.microsoft.com/download)
- 一台 OpenAI 兼容的 Chat Completions 服务(默认配置指向 DeepSeek)
- 访问天气接口的网络(wttr.in 或 OpenWeatherMap
---
## 快速开始
### 1. 还原并编译
```bash
cd 仓库根目录
dotnet restore
dotnet build
```
### 2. 配置模型
不要把 API Key 提交进 Git。任选一种方式:
**方式 A:环境变量(推荐)**
```powershell
$env:OPENAI_API_KEY = "你的密钥"
$env:OPENAI_ENDPOINT = "https://api.deepseek.com"
$env:OPENAI_CHAT_MODEL = "deepseek-v4-flash"
```
也识别:
| 变量 | 含义 |
|------|------|
| `OPENAI_API_KEY` / `AZURE_OPENAI_API_KEY` | 密钥 |
| `OPENAI_ENDPOINT` / `OPENAI_BASE_URL` / `AZURE_OPENAI_ENDPOINT` | 服务地址 |
| `OPENAI_CHAT_MODEL` / `AZURE_OPENAI_DEPLOYMENT_NAME` | 模型名或 Azure 部署名 |
配置优先级:环境变量会覆盖 `appsettings.json` 里空的或未填的对应项(见 `AgentFactory.Load`)。
**方式 B`appsettings.json` 的 `Llm` 节点**
```json
"Llm": {
"ApiKey": "",
"Endpoint": "https://api.deepseek.com",
"Model": "deepseek-v4-flash"
}
```
Azure OpenAI:把 Endpoint 设成 Azure 资源地址,模型字段填 **部署名**。程序会按 URL 判断是否走 Azure 客户端。
Visual Studio / `dotnet run --project MAF1.Web --launch-profile designer` 会读 `MAF1.Web/Properties/launchSettings.json` 里的环境变量。该文件容易带上真实密钥,**请勿把含 Key 的版本推到远程**。
控制台 profile 在 `MAF1/Properties/launchSettings.json``node` / `edge`)。
### 3. 启动可视化编排
```bash
dotnet run --project MAF1.Web
```
浏览器打开 <http://127.0.0.1:5288>。控制台会打印:`可视化编排: http://127.0.0.1:5288`
界面操作建议:
1.**示例图**,生成「抽城市 → 查天气」的演示图(含边条件)。
2. 选中节点,在右侧填 `filePath`(例如 `Data/cities.txt`),或检查端口映射。
3.**运行**,底部查看每步输入 / 输出。
4. 改完 `plugins/` 后点 **刷新节点** 重新加载目录。
### 4. 命令行工作流
```bash
dotnet run --project MAF1 -- node Data/cities.txt # 条件在 Gate 节点内部
dotnet run --project MAF1 -- edge Data/cities.txt # 条件在边上分流
dotnet run --project MAF1 -- --help
```
无参数时默认 `node Data/cities.txt`。无有效城市时可用 `Data/not-cities.txt`。工作流会在控制台询问如何继续(默认结束查询,也可输入其它城市);超时走默认方案。
```bash
dotnet run --project MAF1 -- node Data/not-cities.txt
dotnet run --project MAF1 -- edge Data/not-cities.txt
```
控制台会出现:
```
[需要确认]
没有读到有效城市。…
1) 结束查询(默认) ← 默认
2) 改查其它城市
输入序号,或直接回车采用默认:
```
输入 `1` 或回车结束;输入 `2` 后再填城市名继续查天气;也可以直接输入 `成都`。等待秒数与网页相同,来自 `Workflow:DecisionTimeoutSeconds`
### 5. 命令行路由(方案 C
`MAF1.Route` 用 Microsoft Agents AI 的 **`WithHandoff`** 跑方案 C:图 JSON 是专家白名单,边是允许交接。主管是入口 Agent,框架给每条边注入 `handoff_to_*` 工具,控制权随对话转交。
- **节点** = 白名单。图上没有的类型不能调。
- **有边** = 只能沿边 `WithHandoff``Graphs/city-weather.json`:必须先抽城市再查天气)。
- **无边** = 主管可交接给名单内任一专家(`Graphs/nodes-only.json`:可以直接查「成都」天气)。
抽不到城市时,抽城市专家**不交接**,本轮结束(Handoff 不像工作流 CLI 那样弹确认框)。
```bash
dotnet run --project MAF1.Route
dotnet run --project MAF1.Route -- Graphs/city-weather.json
dotnet run --project MAF1.Route -- Graphs/city-weather.json Data/not-cities.txt
dotnet run --project MAF1.Route -- Graphs/nodes-only.json 请查询成都的天气
```
启动配置:
| 项目 | Profile | 作用 |
|------|---------|------|
| `MAF1.Web` | `designer` | 网页编排器 |
| `MAF1` | `node` | CLI,节点内判断 |
| `MAF1` | `edge` | CLI,边上判断 |
| `MAF1.Route` | `route` | CLI 路由,有边白名单 |
| `MAF1.Route` | `nodes-only` | CLI 路由,无边自选顺序 |
---
## 配置说明
`MAF1/appsettings.json``MAF1.Route/appsettings.json``MAF1.Web/appsettings.json` 会分别复制到各自输出目录。网页项目额外包含 `Plugins` / `Credentials`。主要段落:
### Llm
宿主内置 Agent 与默认凭据 `llm-default` 使用这一段(可再被环境变量覆盖)。
### Plugins
| 字段 | 含义 |
|------|------|
| `Directory` | 相对宿主内容根的插件目录,默认 `plugins` |
| `DefaultTimeoutSeconds` | 插件进程默认超时(秒),清单里可单独覆盖 |
插件进程会收到 `MAF1_CONTENT_ROOT`,用于定位宿主的 `appsettings.json`(天气插件读天气配置)。
### Credentials
节点只声明凭据类型。`llm-default``source: llm-section` 表示从 `Llm` 段生成,不在 JSON 里再写一份 Key。
### Weather
| 字段 | 含义 |
|------|------|
| `Provider` | `Wttr`(默认)或 `OpenWeather` |
| `Language` | 语言,默认 `zh` |
| `Wttr.UrlTemplate` | `{location}``{lang}` 占位 |
| `OpenWeather.UrlTemplate` / `ApiKey` | OpenWeatherMap;需自行申请 Key |
### Workflow
| 字段 | 含义 |
|------|------|
| `DecisionTimeoutSeconds` | 需要用户确认时的等待秒数,默认 `30`。超时后采用默认方案。 |
节点 `config` 可覆盖:`decisionTimeoutSeconds`(秒)、`askOnEmptyCities``false` 时抽不到城市不询问,直接按边条件跳过)。
---
## 内置 Agent
两条链路语义相同,只是执行位置不同。
### FileCity(抽城市)
- 输入:`filePath`(本地文本路径,相对宿主工作目录即可)
- 输出:`hasValidCities``cities``reason``raw`
- 行为:LLM + 读文件工具,从文本里抽出有效城市名
- 若没有有效城市且后面还有节点:工作流会 **暂停等待确认**。默认方案是结束后续查询;可选方案是手动输入城市继续。超时走默认。
系统节点类型:`fileCity-system`
插件 id`fileCity``plugins/file-city`
### Weather(查天气)
- 输入:`cities`(字符串数组)
- 输出:`summary`
- 行为:LLM + HTTP 天气工具,汇总各城市天气
系统节点类型:`weather-system`
插件 id`weather``plugins/weather`
画布上可以混用:例如系统 FileCity 接到插件 Weather,只要端口名对得上。
---
## 可视化图模型
运行请求体大致为:
```json
{
"nodes": [
{
"id": "n1",
"type": "fileCity-system",
"title": "抽城市",
"x": 80,
"y": 120,
"config": { "filePath": "Data/cities.txt" }
}
],
"edges": [
{
"id": "e1",
"from": "n1",
"to": "n2",
"fromPort": "cities",
"toPort": "cities",
"when": "hasValidCities"
}
]
}
```
- **拓扑**:按依赖排序执行;未满足入边条件的节点会被标记为跳过。
- **人机决策**:节点输出 `hasValidCities = false` 时,若后面还有节点,则返回 `status: needsDecision`,不立刻跑完。
- **端口**`fromPort``toPort` 把上游输出接到下游输入;未接线的必填项可写在 `config`
- **边条件 `when`**(当前实现):
- 空:始终通过
- `hasValidCities`:上游输出该布尔为真
- `!hasValidCities`:为假
- 其它字符串:视为通过
---
## HTTP API
端口固定:`http://127.0.0.1:5288``MAF1.Web/Program.cs`)。
| 方法 | 路径 | 说明 |
|------|------|------|
| `GET` | `/api/catalog` | 系统节点 + 插件节点、扫描问题、`pluginsRoot` |
| `GET` | `/api/credentials` | 对外凭据列表(不含原始 Key) |
| `GET` | `/api/status` | 当前天气 Provider、插件根路径 |
| `POST` | `/api/run` | Body 为工作流图 JSON。可能直接跑完,也可能返回 `needsDecision` |
| `GET` | `/api/run/{runId}` | 查询暂停中或已结束的一次运行 |
| `POST` | `/api/run/{runId}/decide` | Body`{ "optionId": "stop" }``{ "optionId": "query-cities", "text": "成都" }` |
`POST /api/run` 在需要确认时返回:
```json
{
"ok": true,
"status": "needsDecision",
"runId": "…",
"decision": {
"prompt": "没有读到有效城市…",
"defaultOptionId": "stop",
"timeoutSeconds": 30,
"deadline": "2026-08-27T09:22:00+00:00",
"options": [
{ "id": "stop", "label": "结束查询(默认)", "isDefault": true },
{ "id": "query-cities", "label": "改查其它城市", "requiresText": true }
]
},
"steps": []
}
```
默认方案必须存在。服务端到期会自动按 `defaultOptionId` 继续;客户端倒计时结束后可再 `GET` 一次拿最终结果。
JSON 使用 camelCase。静态站点来自 `wwwroot`
---
## 插件机制
约定详见 [`plugins/README.md`](plugins/README.md)。每个子目录一份插件。
### 必备文件
- `plugin.json`id、protocolVersion、启动命令、凭据声明、inputs、outputs
- `README.md`:给人看的说明
- 可执行文件:由 `launch.command` + `launch.args` **原样**启动,宿主不替你拼路径
示例(FileCity):
```json
"launch": {
"command": "dotnet",
"args": ["FileCityPlugin.dll"]
}
```
工作目录是该插件文件夹;`dotnet FileCityPlugin.dll` 能在该目录下找到 dll。
### 进程协议
**stdin**(一次请求):
```json
{
"protocolVersion": 1,
"inputs": { "filePath": "Data/cities.txt" },
"credentials": {
"llm": {
"id": "llm-default",
"type": "openai-compatible",
"endpoint": "...",
"apiKey": "...",
"model": "..."
}
}
}
```
**stdout**:单个 JSON 对象,字段名与 `outputs` 一致。
**stderr**:日志。不要把日志打到 stdout,否则会破坏解析。
超时:清单 `timeoutSeconds`,否则用宿主 `Plugins:DefaultTimeoutSeconds`(默认 180)。
### 凭据注入
与 n8n / Dify 类似:Key 只在宿主。只注入 `plugin.json` 声明过的凭据。运行插件时会:
1. 按 type 写入环境变量(LLM 为 `OPENAI_*`OpenWeather 为 `OPENWEATHER_API_KEY`
2. 在 stdin JSON 的 `credentials` 里再传一份
JSON Schema[docs/plugin-protocol-v1.schema.json](docs/plugin-protocol-v1.schema.json)。`plugin.json``protocolVersion` 缺省视为 1;大于 1 的清单会被扫描拒绝。
插件目录可选 `.env` 作为第三方自带模型的默认环境;节点选中的宿主凭据会覆盖同名变量。**不要把 `.env` 提交进仓库。** 非秘密配置放在插件自己的 `appsettings.json`,不要读宿主 exe 目录。
更细的输入输出说明:
- [`plugins/file-city/README.md`](plugins/file-city/README.md)
- [`plugins/weather/README.md`](plugins/weather/README.md)
### 自己加插件
1. 新建 `plugins/你的插件/`,写 `plugin.json` 和启动程序。
2. 若要随网页宿主一起编译,可仿照 `MAF1.Web.csproj` 增加 `ProjectReference``ReferenceOutputAssembly=false`)和 `PublishPluginFolders` 复制规则。
3. `id` 不要与系统节点类型冲突;若同名,目录会标记覆盖关系。
4. 重启宿主或点「刷新节点」,确认 `/api/catalog``issues` 为空。
---
## 架构
```
浏览器 MAF1.Web/wwwroot
│ REST
MAF1.Web Program.csMinimal API
AgentRuntime
├── NodeCatalogService 合并系统节点 + PluginScanner
├── CredentialStore llm-default 等
├── ConfigurableWorkflowRunner 拓扑、端口、when、逐步日志
├── 系统 Handler FileCity / Weather(进程内)
└── PluginProcessRunner 子进程 stdin/stdout
```
控制台项目不走画布:
`MAF1``CliHost``CityWeatherWorkflow`node / edge)→ `WorkflowOrchestration`。缺城市时走 `RequestPort`,与网页同一套默认方案 + 超时。
`MAF1.Route``HandoffGraphBuilder``CreateHandoffBuilderWith` + `WithHandoff`)→ Core 里的 FileCity / Weather。图 JSON 决定白名单和允许交接;**原来的 workflow CLI 仍在 MAF1 里。**
`MAF1.Core` 被控制台、路由、网页与插件共用,避免两套 Agent 逻辑分叉。
---
## 示例数据
`Data/cities.txt`
```
成都
阿姆斯特丹
北京
```
`Data/not-cities.txt`:不含有效城市,用于验证「无城市则询问后默认不查天气」。
这些文件会随构建复制到各项目输出目录。CLI 传入相对路径时,请在仓库根目录用 `--project MAF1` 运行,或在已复制 Data 的输出目录下运行。
---
## 常见问题
**启动报「还没有配置模型」**
`Llm:ApiKey` 和环境变量都为空。按「快速开始」配置后重启。
**画布上看不到插件**
插件必须出现在 **MAF1.Web.exe 旁边**`plugins/`。先 `dotnet build MAF1.Web/MAF1.Web.csproj`,确认 `MAF1.Web/bin/Debug/net10.0/plugins/file-city/plugin.json` 存在。源码目录里的 `plugins/` 不会被运行时直接扫描。
**插件超时或卡住**
加大 `timeoutSeconds`;确认 LLM 与天气 HTTP 可访问;日志看 stderr。
**天气失败**
默认走 wttr.in。若被墙或限流,把 `Weather:Provider` 改为 `OpenWeather` 并填写 Key。
**Windows 控制台中文乱码**
项目带 `app.manifest` 并在入口启用 UTF-8。若仍乱码,把终端代码页设为 UTF-8。
**端口被占用**
当前 URL 写死为 `127.0.0.1:5288`。关掉占用进程,或临时改 `MAF1.Web/Program.cs` / `MAF1.Web/Properties/launchSettings.json`
**没有自动化测试 / Docker**
仓库目前没有测试项目和容器文件。验证方式:UI 示例图 + CLI `node` / `edge` + `MAF1.Route`
---
## 安全注意
- LLM Key、OpenWeather Key 只放在本机配置或环境变量,不要写进 `plugin.json`、流程图或前端。
- `/api/credentials` 只返回公开元数据。
- 若 Key 曾经出现在 `launchSettings.json` 并被提交或分享,请到服务商控制台轮换密钥。
---
## 许可与定位
本仓库是多智能体编排与进程外插件的**可运行演示**,便于对照系统节点与插件、对照 CLI 的 node / edge 两种条件写法。按你自己的许可证要求补充版权声明即可。