Skip to main content
Glama
README.md
<div align="center">

# laya-mcp

**把 Laya System-1 决策模型封装成一个工程化的 MCP Server(FastAPI 宿主)**

一次前向传播回答一组「带类型的问题」,返回**校准概率** + **可直接分支执行的决策契约**,
让任意支持 MCP 的 Agent(Claude Code / Codex / Cursor / 自研 Agent)都能在**毫秒级**完成自动决策。

[![Python](https://img.shields.io/badge/python-3.13-3776AB?logo=python&logoColor=white)](https://www.python.org/)
[![FastAPI](https://img.shields.io/badge/FastAPI-0.141-009688?logo=fastapi&logoColor=white)](https://fastapi.tiangolo.com/)
[![MCP](https://img.shields.io/badge/MCP-2.2-000000)](https://modelcontextprotocol.io/)
[![Laya](https://img.shields.io/badge/laya-0.3.5-FF6F00)](https://huggingface.co/convaiinnovations/laya)

</div>

---

## 目录

- [这是什么](#这是什么)
- [核心概念:决策契约(Contract)](#核心概念决策契约contract)
- [为什么值得用](#为什么值得用)
- [架构](#架构)
- [快速开始](#快速开始)
- [接入 MCP Agent](#接入-mcp-agent)
- [MCP 工具参考](#mcp-工具参考)
- [Resources 与 Prompts](#resources-与-prompts)
- [内置问题集(Preset)](#内置问题集preset)
- [问题(Question)Schema](#问题question-schema)
- [REST API](#rest-api)
- [命令行 CLI](#命令行-cli)
- [配置项(环境变量)](#配置项环境变量)
- [Docker 部署](#docker-部署)
- [开发与测试](#开发与测试)
- [常见问题 FAQ](#常见问题-faq)
- [许可证](#许可证)

---

## 这是什么

`laya-mcp` 把 [Laya](https://huggingface.co/convaiinnovations/laya)(一个 **System-1** 决策模型族)
包装成一个**常驻服务**,同时对外暴露两种接口:

| 接口 | 地址 | 面向谁 |
| --- | --- | --- |
| **MCP Streamable HTTP** | `http://127.0.0.1:8077/mcp` | 任意 MCP 客户端 / Agent |
| **REST API** | `http://127.0.0.1:8077/v1/...` | 脚本、看板、健康检查、非 MCP 调用方 |
| **OpenAPI 文档** | `http://127.0.0.1:8077/docs` | 人 |

> 它不是「又一个 LLM 接口」。Laya 是**非生成式**的:一次 forward pass 回答**一组结构化问题**,
> 输出的是**校准过的概率分布**,而不是一段文本。这使得它可以在 10~50ms 内做出「该走哪条路」的判断,
> 成本约为调用一次完整 LLM 的千分之一。

### 与传统做法对比

`laya-test.py`(见 [`examples/quickstart_router.py`](examples/quickstart_router.py))演示了最小用法:
加载 Router → 构造问题 → `router.predict()` → 用阈值手写 `if`。

这个工程把它**产品化**了:

| 关注点 | `laya-test.py` | `laya-mcp` |
| --- | --- | --- |
| 模型驻留 | 每次进程启动重新加载 | 常驻单例 + 启动预热(warmup) |
| 阈值策略 | 每个调用方自己写 `if conf >= 0.8` | **决策契约**,由服务统一输出 `grade` |
| 并发 | 单线程阻塞 | 线程池 + 事件循环不阻塞 + 结果缓存 |
| 接入方式 | 只能进程内 Python 调用 | MCP / REST / CLI 三种入口共用同一套逻辑 |
| 可观测性 | `print` | 结构化日志 + 指标(计数 / 延迟分位) + `/healthz` |
| 错误处理 | 直接抛异常 | 统一错误码 + HTTP 状态映射 |
| 配置 | 硬编码 | `LAYAMCP_*` 环境变量 / `.env` |
| Agent 可发现性 | 无 | 工具自描述 + resources 知识库 + prompt 模板 |

---

## 核心概念:决策契约(Contract)

**这是整个项目最重要的设计。** 模型给出的是概率,但 Agent 需要的是**策略**:
「我现在可以直接执行吗?还是必须找人确认?」

原始概率无法直接回答这个问题——两个策略 0.79 / 0.78 时,最高分看起来很高,但**它不是一个决策**。
因此每次调用都会额外返回一个 `contract` 块,把概率翻译成 4 种 **grade**,Agent 直接分支即可:

| `grade` | 含义 | Agent 应该做什么 |
| --- | --- | --- |
| ✅ `auto_execute` | 置信度越过阈值,且与次优有明显差距 | **直接执行**,不要再花 token 重新推理 |
| ⚠️ `escalate` | 高置信但存在近距平局,或问题本身语义上就该由人拍板 | 不要仅凭模型答案行动,走完整 LLM 分析或请人确认 |
| 🛑 `ask_human` | 低置信 + 高风险场景 | **必须转人工** |
| 🔁 `reconsider` | 低置信 + 低风险 | 可以重试:换更精确的 criteria、增加候选、或交给 LLM |

### grade 由三个信号共同决定(而不是单一置信度)

| 信号 | 作用 | 为什么单独看它不行 |
| --- | --- | --- |
| `confidence` | 校准后的置信度 | 候选数量多时 softmax 会被抬高,必须用 Laya 的温度校准值 |
| `margin` | top1 − top2 | 0.79 / 0.78 的两条相邻策略,不管最高分多高都不构成决策 |
| `needs_review` | 模型自带的「需人工复核」头 | 对语义上就要求人拍板的问题,**高置信也依然是 escalate** |

### 契约结构示例

```json
{
  "grade": "auto_execute",
  "autonomous": true,
  "requires_human": false,
  "threshold": 0.8,
  "margin_threshold": 0.05,
  "decisions": {
    "selected_strategy": {
      "value": "redis_cache",
      "confidence": 0.9231,
      "margin": 0.41,
      "grade": "auto_execute",
      "reason": "confidence 0.92 clears 0.80 and beats the runner-up by 0.41",
      "requires_human": false
    }
  },
  "grade_counts": { "auto_execute": 1, "escalate": 0, "ask_human": 0, "reconsider": 0 },
  "reasons": [],
  "summary": "all 1 answers clear the auto-execute bar; proceed",
  "routing": { "model": "english", "reason": "English Latin text" }
}
```

> **聚合规则:一个被卡住的问题会卡住整个请求。** 整体 `grade` 取所有问题中最「保守」的那个
> (优先级 `ask_human` > `escalate` > `reconsider` > `auto_execute`),
> 因为对 Agent 而言「部分可执行」比「整体不可执行」更危险。

### Agent 典型分支伪代码

```python
result = mcp.call("decide", state=diff, preset="code_change_risk")
grade = result["contract"]["grade"]

if grade == "auto_execute":
    merge_pull_request()                       # 直接干,零推理成本
elif grade == "escalate":
    run_full_llm_review(diff)                  # 花 token 深挖
elif grade == "ask_human":
    notify_reviewer(result["contract"]["reasons"])
else:  # reconsider
    result = mcp.call("decide", state=diff, preset="code_change_risk",
                      threshold="critical")    # 换阈值 / 换问题重试
```

---

## 为什么值得用

- **大幅降低 Agent 决策成本**:把「要不要做 / 走哪条路」交给 System-1 模型,避免为此唤起完整 LLM 的 CoT。
- **不引入幻觉风险**:非生成式,输出是封闭候选集上的概率,不会「编造」答案。
- **策略集中在服务端**:阈值、margin、风险等级都是**配置**,不用在每个 Agent 里重复实现。
- **对 Agent 友好**:工具自带详细 description 与 `structuredContent`,另有 `laya://guide` 等资源做知识注入。
- **一套逻辑三种入口**:MCP / REST / CLI 共用 `laya_mcp.core`,不会出现行为漂移。
- **生产可用**:健康检查、指标、结构化日志、超时与体积上限、线程安全、优雅降级(模型加载失败时 `/healthz` 返回 `degraded` 而非崩溃)。

---

## 架构

```
                        ┌─────────────────────────────┐
   MCP Agent  ────────► │  /mcp  StreamableHTTP       │
 (Claude Code /         │  (9 tools / 4 resources /   │
  Codex / Cursor)       │   2 prompts)                │
                        ├─────────────────────────────┤
   脚本 / 看板 ────────► │  /v1/...  REST (FastAPI)    │
                        └──────────────┬──────────────┘
                                       │  共用同一套实现
                        ┌──────────────▼──────────────┐
                        │        laya_mcp.core        │
                        │  settings / schema / preset │
                        │  engine(常驻+池+缓存)         │
                        │  contract(决策契约) / metrics │
                        └──────────────┬──────────────┘
                                       │
                        ┌──────────────▼──────────────┐
                        │  laya.Router (System-1)     │
                        │  english / multilingual /   │
                        │  typed-decisions            │
                        └─────────────────────────────┘
```

### 目录结构

```
laya-mcp/
├── src/laya_mcp/
│   ├── core/                 # 与传输方式无关的领域层
│   │   ├── settings.py       #   LAYAMCP_* 配置、阈值等级
│   │   ├── schema.py         #   公开问题 schema ↔ Laya 原生格式
│   │   ├── presets.py        #   5 个内置问题集
│   │   ├── contract.py       #   ★ 概率 → grade 的决策契约
│   │   ├── engine.py         #   单例 Router + 线程池 + LRU 缓存
│   │   ├── errors.py         #   统一错误分类与 HTTP 映射
│   │   └── metrics.py        #   计数 / 延迟分位
│   ├── api/                  # FastAPI 层
│   │   ├── app.py            #   create_app(),挂载 /mcp 与 /v1
│   │   ├── routes.py         #   REST 端点(不含决策逻辑)
│   │   ├── schemas.py        #   请求 / 响应模型
│   │   └── dependencies.py   #   DI:engine / settings / metrics
│   ├── mcp_server/           # MCP 层
│   │   ├── server.py         #   工具 / 资源 / Prompt 注册
│   │   └── questions.py      #   问题解析(inline / preset / 快捷参数)
│   ├── cli.py                # 命令行入口
│   └── logging_config.py     # 结构化日志
├── examples/quickstart_router.py   # 原始 laya-test.py 用法
├── tests/
└── pyproject.toml
```

### 关键实现说明

- **模型只加载一次**:`DecisionEngine` 进程级单例。启动时预热(`LAYAMCP_WARMUP=true`),
  之后每次请求都是热态,毫秒级返回。
- **不阻塞事件循环**:推理跑在有界线程池里(`LAYAMCP_WORKER_THREADS`),
  torch 内部会释放 GIL,因此 CPU 上并发也有收益。
- **结果缓存**:对相同 `(state, questions)` 做进程内 LRU 缓存(默认 256 条),
  Agent 重试同样的问题几乎零成本;响应中 `cached=true` 可辨别。
- **MCP 挂载方式(容易踩坑)**:**不能**用 `streamable_http_app()` 再 `mount` 到 FastAPI——
  Starlette 不会为子应用执行 lifespan,session manager 起不来,`/mcp` 会全部失败。
  正确做法是在**本应用**的 lifespan 里进入 session manager:

  ```python
  manager = StreamableHTTPSessionManager(app=server._lowlevel_server, ...)
  app.mount("/mcp", StreamableHTTPASGIApp(manager))
  # lifespan: async with manager.run(): yield
  ```

  参见 [`src/laya_mcp/api/app.py`](src/laya_mcp/api/app.py) 的模块注释。
- **MCP SDK 版本**:基于 **mcp 2.x**(`from mcp.server.mcpserver import MCPServer`)。
  与 1.x 的 `mcp.server.fastmcp.FastMCP` **不兼容**,升级时注意导入路径变化。

---

## 快速开始

### 环境要求

- Python **3.13+**
- [uv](https://docs.astral.sh/uv/)(推荐)
- 磁盘约 **3 GB**(三个 checkpoint 合计约 2.2 GB)
- CPU 即可运行;有 CUDA / Apple MPS 会自动加速
- 首次运行需要联网从 HuggingFace 下载权重,之后可完全离线

### 安装

```bash
git clone <your-repo-url> laya-mcp
cd laya-mcp
uv sync                       # 创建 .venv 并安装依赖
```

### 自检

```bash
uv run laya-mcp doctor
```

输出示例:

```
laya-mcp 0.2.0
python: 3.13.x
settings: models=['english', 'multilingual'] device=auto port=8077
laya: 0.3.5 -> .../laya/__init__.py
torch: 2.x.x cuda=False
HF 缓存: 2.20 GB,包含 laya 检查点: True
fastapi: ok
uvicorn: ok
mcp: ok
pydantic_settings: ok
结论: 环境可用
```

### 启动服务

```bash
uv run laya-mcp serve
```

```
INFO  laya_mcp.engine  loading laya router  models=['english','multilingual'] device=cpu
INFO  laya_mcp.engine  laya router ready  loaded=['english','multilingual']
INFO  laya_mcp.api     engine ready in 67.8s
INFO  mcp.server...    StreamableHTTP session manager started
Uvicorn running on http://127.0.0.1:8077
```

- 服务首页:<http://127.0.0.1:8077/>
- OpenAPI 文档:<http://127.0.0.1:8077/docs>
- MCP 端点:`http://127.0.0.1:8077/mcp`

> ⏳ **首次启动约 60~90 秒**(在 CPU 上加载两个 checkpoint)。之后所有请求都是热态,
> 单次决策通常 **10~50ms**。

### 第一条请求

```bash
curl -s http://127.0.0.1:8077/v1/decide \
  -H 'content-type: application/json' \
  -d '{
    "state": "Fix a typo in README, one file, no interface change",
    "preset": "code_change_risk"
  }' | python -m json.tool
```

或者完全不启服务,直接一次性决策:

```bash
uv run laya-mcp ask \
  --state "Fix a typo in README, one file, no interface change" \
  --preset code_change_risk
```

---

## 接入 MCP Agent

服务启动后,**任何支持 MCP 的客户端**都可以直接接入。关键信息只有一条:

```
MCP Streamable HTTP 端点:http://127.0.0.1:8077/mcp
```

### Claude Code

```bash
claude mcp add --transport http laya http://127.0.0.1:8077/mcp
```

### Codex / Cursor / 通用 `mcp.json`

```json
{
  "mcpServers": {
    "laya": {
      "type": "streamable-http",
      "url": "http://127.0.0.1:8077/mcp"
    }
  }
}
```

### 只给单个 Agent 用(stdio,无 HTTP)

不想起 HTTP 服务时,可以用 stdio 传输,由客户端自己拉起进程:

```json
{
  "mcpServers": {
    "laya": {
      "command": "uv",
      "args": ["run", "--directory", "/abs/path/to/laya-mcp", "laya-mcp", "serve", "--transport", "stdio"]
    }
  }
}
```

> stdio 模式下进程由客户端按需拉起,**建议启动后先让 Agent 调一次 `preload_warmup`**,
> 否则第一次决策会等待模型加载。HTTP 模式则无需关心——服务启动时已预热。

### 让 Agent「自动决策」的推荐接入方式

1. **在系统提示里注入用法**。服务已经在 MCP `instructions` 里写了使用指南,
   另外可以让 Agent 读取 `laya://guide` 资源获取完整的问题编写规范。
2. **强制 Agent 先查契约**。约定:任何有副作用的动作前,先调用 `decide` / `should_escalate`,
   并**只在 `contract.grade == "auto_execute"` 时**直接执行。
3. **按风险等级传 `threshold`**。删库、发钱、改线上配置这类动作,传 `"high"` 或 `"critical"`。
4. **用 `preset` 起步,再按需内联问题**。preset 与内联 `questions` 会**合并到同一次前向传播**,
   边际成本几乎为零。

---

## MCP 工具参考

共 **9 个工具**,全部返回结构化结果(文本 + `structuredContent` 字段同名)。

| 工具 | 用途 | 是否加载模型 | 典型耗时 |
| --- | --- | --- | --- |
| `decide` | **主力工具**:对 `state` 回答一组带类型的问题,返回答案 + 契约 | 是 | 10~50ms |
| `decide_yes_no` | 最省成本:单个是非问题 + 契约 | 是 | ~10ms |
| `decide_shortlist` | 候选标签极多(几十~上百)时,先 embedding 粗筛 top-k 再决策 | 是 | 20~80ms |
| `route_request` | 只做路由:判断语言 / 脚本 / 工作流,返回该用哪个 checkpoint 及原因 | **否** | <1ms |
| `list_presets` | 列出全部内置问题集及其 question id | 否 | <1ms |
| `describe_questions` | 回显并校验一组问题(id、kind、候选、等级),不推理 | 否 | <1ms |
| `should_escalate` | 一站式:给定动作,直接回答「是否需要人工介入」 | 是 | 10~50ms |
| `server_status` | 就绪状态、已加载 checkpoint、缓存条数、延迟分位 | 否 | <1ms |
| `preload_warmup` | 强制加载 checkpoint(仅 stdio 或关闭预热时需要) | 是(慢) | 30~90s |

### 调用顺序建议(先便宜后昂贵)

```
route_request  <  list_presets / describe_questions  <  decide_yes_no  <  decide  <  decide_shortlist
   (<1ms)                  (<1ms)                      (~10ms)         (10~50ms)      (20~80ms)
```

### `decide` 参数速查

| 参数 | 类型 | 说明 |
| --- | --- | --- |
| `state` | string \| object \| array | **必填**。模型真正读到的上下文:代码 / diff / 工单 / trace / 工具返回值。**放原始材料,不要放摘要**,摘要会掉准确率 |
| `questions` | object \| array | 内联问题集,见 [Question Schema](#问题question-schema) |
| `preset` | string | 内置问题集名;可与 `questions` 合并 |
| `threshold` | number \| string | `auto_execute` 所需置信度。数字 ∈ [0,1],或等级 `trivial/low/normal/high/critical` |
| `model` | string | 强制 checkpoint:`english` / `multilingual` / `typed-decisions` |
| `lang` | string | 提示 `state` 的语言(如 `zh` / `en`),跳过语言检测 |
| `task` | string | 强制使用 `typed-decisions` 的四个工作流之一 |
| `use_cache` | bool | 是否复用相同请求的缓存结果,默认 `true` |

### 返回值结构

```jsonc
{
  "contract": { /* 见上文「决策契约」 */ },
  "grade": "auto_execute",          // 整体 grade,Agent 只需看这个
  "autonomous": true,               // 是否可完全自主执行
  "requires_human": false,
  "summary": "all 5 answers clear the auto-execute bar; proceed",
  "answers": {
    "blast_radius": {
      "kind": "choice",
      "value": "single_file",
      "confidence": 0.9642,
      "margin": 0.8713,
      "grade": "auto_execute",
      "reason": "confidence 0.96 clears 0.80 and beats the runner-up by 0.87",
      "requires_human": false,
      "probabilities": { "single_file": 0.9642, "module": 0.0929, "...": 0.0 },
      "raw": { /* Laya 原始返回,未加工 */ }
    }
  },
  "routing": { "model": "english", "reason": "English Latin text", "repo": "convaiinnovations/laya" },
  "latency_ms": 31.4,
  "cached": false,
  "usage": {}
}
```

---

## Resources 与 Prompts

除了工具,服务还暴露 MCP **Resources**(可被 Agent 当知识库读取)与 **Prompts**(可复用模板)。

### Resources

| URI | 内容 |
| --- | --- |
| `laya://guide` | **问题编写指南**、kind 说明、grade 语义(强烈建议 Agent 读取) |
| `laya://schema` | `decide` / `describe_questions` 接受的问题 JSON Schema |
| `laya://presets` | 全部内置问题集(JSON) |
| `laya://status` | 实时就绪状态与延迟,等同 `server_status` |

### Prompts

| 名称 | 用途 |
| --- | --- |
| `decide_before_acting` | 把「打算执行的动作」转成一组问题集的填空模板 |
| `triage_message` | 对一条用户消息做分诊(意图 / 紧急度 / 情绪 / 流失风险) |

---

## 内置问题集(Preset)

Preset 是**问题的骨架**,不是答案。它们覆盖了 Agent 最常遇到的五类场景:

| 名称 | 问题数 | 适用场景 | 包含的 question id |
| --- | --- | --- | --- |
| `code_change_risk` | 5 | 代码改动 **前置风险评审**:爆炸半径、可回滚性、数据影响、测试覆盖、是否需人工 | `blast_radius`, `reversible`, `data_impact`, `test_coverage`, `needs_human_review` |
| `support_triage` | 4 | **客服工单分诊**:意图、紧急度、情绪、流失风险 | `intent`, `is_urgent`, `frustration`, `churn_risk` |
| `input_guard` | 5 | **不可信输入的护栏**:越狱、提示注入、敏感数据、危害程度、话题 | `jailbreak`, `prompt_injection`, `sensitive_data`, `harm_severity`, `topic` |
| `routing` | 4 | **模型/算力路由**:难度、领域、是否需要工具、敏感性 | `difficulty`, `domain`, `needs_tools`, `is_sensitive` |
| `typed_decisions_agent_trace` | 5 | **Agent trace 可观测性**(与 `typed-decisions` checkpoint 的工作流对齐) | `action`, `needs_review`, `outcome`, `risk`, `urgency` |

```bash
# 查看全部 preset
uv run laya-mcp presets
# 查看单个 preset 的完整问题定义
curl -s http://127.0.0.1:8077/v1/presets/code_change_risk | python -m json.tool
```

**preset + 自定义问题可以合并**,一次前向传播全部回答:

```json
{
  "state": "<diff 或工单>",
  "preset": "code_change_risk",
  "questions": {
    "target_release": {
      "kind": "choice",
      "instructions": "这个改动更适合进哪个版本?",
      "criteria": {
        "hotfix": "紧急修复,必须立刻发",
        "next_minor": "下个小版本即可",
        "backlog": "不急,排入待办"
      }
    }
  }
}
```

---

## 问题(Question)Schema

Laya 原生词汇是 `choice` / `score` / `noul`(且使用 `t` / `ins` / `crit` 短键)。
本工程定义了一套**稳定、自描述**的公开 schema,调用方不需要了解底层格式:

```jsonc
{
  "question_id": {
    "kind": "choice",                  // choice | score | yes_no
    "instructions": "选哪个方案?",       // 给模型的指令
    "criteria": {                      // 见下方「三种 kind」
      "redis_cache": "适合读多写少、容忍秒级延迟",
      "db_index": "适合查询条件固定、数据量中等"
    },
    "weight": 1.0,                     // 可选,建议性权重
    "description": "..."               // 可选,仅在罗列时展示
  }
}
```

### 三种 `kind`

| `kind` | 对应 Laya 类型 | `criteria` 格式 | 说明 |
| --- | --- | --- | --- |
| `choice` | `choice` | `{标签: 何时选它}` | N 选 1。**每个候选的适用条件要写具体、可区分** |
| `score` | `score` | `["等级0", "等级1", ...]` | 有序等级列表,返回等级下标 |
| `yes_no` | `noul` | `{"true": "应该成立时的陈述"}` | 是非问题。**陈述句要正向表述**(true 表示「可以继续」) |

### 编写要点(直接影响准确率)

1. **一个问题只问一件事**。不要问「这个改动安全吗」,而是拆成爆炸半径 / 可回滚 / 数据影响。
2. **候选之间必须互斥且穷尽**。留一个 `other` 兜底往往比硬选更好。
3. **criteria 写判定条件,不写形容词**。✅「涉及共享 schema,会被其他团队消费」 ❌「影响很大」。
4. **`state` 放原始材料**,代码、diff、完整工单原文,不要放你自己的总结。
5. **问题数量 2~6 个最佳**(上限由 `LAYAMCP_MAX_QUESTIONS` 控制,默认 32),
   每个问题的边际成本极低——多问一个几乎不增加耗时。
6. **不要用 Laya 做生成任务**。它只做分类/打分,不是写作模型。

### 校验问题集

写好后先让服务回显校验,避免把无效问题发到模型:

```bash
curl -s http://127.0.0.1:8077/v1/questions/describe \
  -H 'content-type: application/json' \
  -d '{"preset": "input_guard"}' | python -m json.tool
```

---

## REST API

Base path:`/v1`。完整交互式文档见 `/docs`。

### 决策

| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `POST` | `/v1/decide` | 回答一组带类型的问题(等同 MCP `decide`) |
| `POST` | `/v1/decide/yes-no` | 单个是非问题 |
| `POST` | `/v1/decide/shortlist` | 大规模候选:embedding 粗筛 + 决策 |
| `POST` | `/v1/route` | 只做路由,不推理(微秒级) |

### 目录 / 运维

| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET` | `/v1/presets` | 全部内置问题集(含问题定义) |
| `GET` | `/v1/presets/{name}` | 单个问题集(规范化后的最终形态) |
| `POST` | `/v1/questions/describe` | 校验并回显一组问题 |
| `GET` | `/v1/models` | 可用 checkpoint 及加载状态 |
| `GET` | `/v1/healthz` | 存活检查(模型加载失败返回 200 + `degraded`) |
| `GET` | `/v1/status` | 引擎状态、阈值、缓存、延迟分位 |
| `GET` | `/v1/metrics` | 原始计数与延迟数据(供 Prometheus 等抓取) |
| `GET` | `/v1/version` | 服务版本与 laya 版本 |

### 示例

```bash
# 1) 是非问题:这个动作能直接执行吗?
curl -s http://127.0.0.1:8077/v1/decide/yes-no \
  -H 'content-type: application/json' \
  -d '{
    "state": {"action": "DROP TABLE users", "env": "production"},
    "statement": "this action is safe to run without human review",
    "threshold": "critical"
  }' | python -m json.tool

# 2) 中文工单分诊(自动路由到 multilingual checkpoint)
curl -s http://127.0.0.1:8077/v1/decide \
  -H 'content-type: application/json' \
  -d '{"state": "客户反馈发票被重复扣款,语气很急", "preset": "support_triage"}' \
  | python -m json.tool

# 3) 只路由,不推理
curl -s http://127.0.0.1:8077/v1/route \
  -H 'content-type: application/json' \
  -d '{"state": "给我翻译一下这段中文"}' | python -m json.tool
# {"model":"multilingual","reason":"non-Latin script (han, 100% of letters); ..."}
```

### 统一错误格式

所有错误都是同一形状,并带 `request_id` 便于排查:

```json
{
  "error": "invalid_question",
  "message": "at most 32 questions per call",
  "details": { "count": 40, "limit": 32 },
  "request_id": "0f3a9c1b2d4e5f60"
}
```

---

## 命令行 CLI

```bash
uv run laya-mcp --help
```

| 命令 | 说明 |
| --- | --- |
| `laya-mcp serve` | 启动服务(默认 FastAPI + MCP Streamable HTTP) |
| `laya-mcp serve --transport stdio` | 仅提供 MCP,走标准输入输出,适合本地 Agent |
| `laya-mcp ask --state ... --choices ...` | **一次性决策**,不启服务(等价于 `laya-test.py` 的用法) |
| `laya-mcp presets` | 列出内置问题集 |
| `laya-mcp tools` | 列出 MCP 工具(**不加载模型**) |
| `laya-mcp doctor` | 检查环境、依赖与 checkpoint 缓存 |

### `serve` 常用参数

```bash
uv run laya-mcp serve \
  --host 0.0.0.0 \
  --port 8077 \
  --models english,multilingual,typed-decisions \
  --device auto \
  --log-level INFO
```

| 参数 | 说明 |
| --- | --- |
| `--transport` | `streamable-http`(默认,含 REST)/ `stdio`(仅 MCP)/ `sse` |
| `--host` / `--port` | 监听地址与端口(默认 `127.0.0.1:8077`) |
| `--models` | 常驻 checkpoint,逗号分隔,或 `all` |
| `--device` | `auto` / `cpu` / `cuda` / `mps` |
| `--reload` | 开发热重载(仅 HTTP) |
| `--no-warmup` | 启动时不加载模型(首次请求会变慢) |

### `ask` 示例

```bash
# N 选 1(--choices 是快捷写法)
uv run laya-mcp ask \
  --state "高并发下数据库查询变慢,Query 全表扫描,QPS 5000/s" \
  --instructions "根据性能瓶颈选择最合适的优化方案" \
  --choices "redis_cache,db_index,read_write_split,search_engine"

# 读取文件作为 state(@ 前缀)
uv run laya-mcp ask --state @./diff.patch --preset code_change_risk

# 输出完整 JSON(含契约)
uv run laya-mcp ask --state "..." --preset input_guard --json

# 是非问题
uv run laya-mcp ask --state "<发布计划>" \
  --statement "this can ship without human approval"
```

`ask` 的可读输出示例:

```
5/5 answers fail the auto-execute bar (...): ask a human before acting

blast_radius: single_file  (choice, confidence 0.64, margin 0.82, ask_human)
needs_human_review: True   (yes_no, confidence 0.60, margin 0.21, ask_human)

checkpoint: english - English Latin text
```

---

## 配置项(环境变量)

所有配置项前缀为 **`LAYAMCP_`**,也可写在项目根目录的 `.env` 里
(见 [`.env.example`](.env.example))。

### 服务

| 变量 | 默认值 | 说明 |
| --- | --- | --- |
| `LAYAMCP_HOST` | `127.0.0.1` | 监听地址 |
| `LAYAMCP_PORT` | `8077` | 监听端口 |
| `LAYAMCP_TRANSPORT` | `streamable-http` | `streamable-http` / `stdio` / `sse` |
| `LAYAMCP_MCP_PATH` | `/mcp` | MCP 挂载路径 |
| `LAYAMCP_API_PREFIX` | `/v1` | REST 前缀 |
| `LAYAMCP_DOCS_ENABLED` | `true` | 是否开启 `/docs` |

### 推理

| 变量 | 默认值 | 说明 |
| --- | --- | --- |
| `LAYAMCP_MODELS` | `english,multilingual` | 常驻 checkpoint,逗号分隔或 `all` |
| `LAYAMCP_DEVICE` | `auto` | `auto` / `cpu` / `cuda` / `mps`(auto 优先 CUDA → MPS → CPU) |
| `LAYAMCP_DEFAULT_MODEL` | `english` | 默认 checkpoint |
| `LAYAMCP_AUTO_TASK_DETECTION` | `true` | 自动识别语言/脚本/工作流并路由 |
| `LAYAMCP_WARMUP` | `true` | 启动时预热加载模型 |
| `LAYAMCP_WORKER_THREADS` | `2` | 推理线程池大小(负载高时调大) |
| `LAYAMCP_HF_TOKEN` | 无 | 私有镜像或受限下载时使用 |

### 策略(决策契约的阈值)

| 变量 | 默认值 | 说明 |
| --- | --- | --- |
| `LAYAMCP_CONFIDENCE_THRESHOLD` | `0.80` | 达到此置信度才可能判为 `auto_execute` |
| `LAYAMCP_MARGIN_THRESHOLD` | `0.05` | top1−top2 低于此值视为「近距平局」→ `escalate` |
| `LAYAMCP_MAX_STATE_CHARS` | `120000` | `state` 最大字符数 |
| `LAYAMCP_MAX_QUESTIONS` | `32` | 单次调用最大问题数 |
| `LAYAMCP_MAX_CHOICES` | `60` | 单个 choice 问题最大候选数 |

### 日志与指标

| 变量 | 默认值 | 说明 |
| --- | --- | --- |
| `LAYAMCP_LOG_LEVEL` | `INFO` | `DEBUG` / `INFO` / `WARNING` / `ERROR` / `CRITICAL` |
| `LAYAMCP_LOG_JSON` | `true` | 结构化 JSON 日志(接入 ELK / Loki 时保留) |
| `LAYAMCP_METRICS_ENABLED` | `true` | 是否采集指标 |
| `LAYAMCP_REQUEST_ID_HEADER` | `x-request-id` | 请求 id 头名称 |

### 置信度等级

`threshold` 参数/配置支持用**名字**代替数字,便于按风险分级:

| 等级 | 数值 | 建议场景 |
| --- | --- | --- |
| `trivial` | 0.60 | 无副作用的分类、打标签 |
| `low` | 0.70 | 内部草稿、低风险建议 |
| `normal` | **0.80**(默认) | 常规 Agent 决策 |
| `high` | 0.90 | 生产变更、影响用户的操作 |
| `critical` | 0.95 | 删数据、动钱、安全相关 |

---

## Docker 部署

```dockerfile
# 参考 Dockerfile
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim

WORKDIR /app
ENV UV_COMPILE_BYTECODE=1 \
    UV_LINK_MODE=copy \
    LAYAMCP_HOST=0.0.0.0 \
    LAYAMCP_PORT=8077 \
    HF_HOME=/models

COPY pyproject.toml uv.lock README.md ./
RUN uv sync --frozen --no-dev --no-install-project

COPY src ./src
RUN uv sync --frozen --no-dev

EXPOSE 8077
CMD ["uv", "run", "--no-dev", "laya-mcp", "serve"]
```

```bash
docker build -t laya-mcp .
docker run --rm -p 8077:8077 -v laya-models:/models \
  -e LAYAMCP_MODELS=english,multilingual \
  laya-mcp
```

> 建议把 `/models` 挂成 volume:首次下载约 2.2 GB,重建镜像不必重下。
> 生产环境请在服务前置反向代理并加鉴权——MCP 端点本身无内建认证。

---

## 开发与测试

```bash
uv sync
uv run laya-mcp doctor          # 环境自检
uv run laya-mcp tools           # 列出工具(不加载模型,适合快速验证)
uv run pytest -q                # 运行测试
```

### 不加载模型做冒烟测试

```bash
# 仅启动 app,不预热(秒级返回)
LAYAMCP_WARMUP=false uv run python -c "
from fastapi.testclient import TestClient
from laya_mcp.api.app import create_app
with TestClient(create_app()) as c:
    print(c.get('/').json())
    print(c.get('/v1/healthz').json())
"
```

### 扩展新工具

1. 在 `src/laya_mcp/core/` 里实现**与传输无关**的逻辑。
2. 在 `src/laya_mcp/mcp_server/server.py` 里注册 `@server.tool(...)`,返回 Pydantic 模型。
3. 若要暴露 REST,在 `src/laya_mcp/api/routes.py` 加端点,**复用同一函数**,不要重写逻辑。
4. 补充 `laya://guide` 里的说明,让 Agent 知道何时该用这个新工具。

---

## 常见问题 FAQ

<details>
<summary><b>首次启动很慢,正常吗?</b></summary>

正常。CPU 上加载 `english` + `multilingual` 两个 checkpoint 约需 60~90 秒。
这是**一次性**成本——服务预热后单次决策仅 10~50ms。

临时验证接口、不想等加载:`--no-warmup`(或 `LAYAMCP_WARMUP=false`),
此时首次决策会变慢。只验证工具列表可用 `uv run laya-mcp tools`(完全不加载模型)。
</details>

<details>
<summary><b>中文/日文输入返回结果很奇怪?</b></summary>

检查 `routing.model` 是否为 `multilingual`。Laya 的 `english` checkpoint 是
ModernBERT-large(512 token,只懂英文),中文会被**自动路由**到 `multilingual`(mmBERT-base,1024 token)。

若自动路由不符合预期,可显式指定:`model="multilingual"` 或 `lang="zh"`。
注意 `multilingual` 上下文更长(1024)但推理略慢,纯英文场景用 `english` 更快更准。
</details>

<details>
<summary><b>置信度看起来偏高 / 与直觉不符?</b></summary>

Laya 返回的是**校准后**的置信度,但候选数量多时原始 softmax 仍会被抬高,
`contract.py` 里的 `confidence_from_probs` 已经做了校正。

另外注意启动时的告警:

```
laya: this checkpoint ships temperatures outside [0.5, 5] ... Treat confidence
from the affected buckets as uncalibrated.
```

命中这类 bucket 时,**不要只看 confidence**,务必参考 `margin` 与整体 `grade`——
这正是契约存在的意义。
</details>

<details>
<summary><b>`/mcp` 全部 404 / 连接失败?</b></summary>

两个常见原因:

1. **挂载方式错误**。不能用 `streamable_http_app()` 再 `mount` 到 FastAPI(子应用 lifespan 不会执行,
   session manager 起不来)。请直接用 `create_app()`。
2. **客户端连接方式不对**。MCP 是**有状态**的 Streamable HTTP,客户端要用 `streamable-http` 传输类型
   指向 `http://<host>:<port>/mcp`;用 SSE 或旧版 HTTP 传输连不上。

若前置了反向代理,请确保**允许流式响应**(关闭 buffering、开启 chunked、放行 `mcp-session-id` 头)。
</details>

<details>
<summary><b>可以完全离线运行吗?</b></summary>

可以。权重下载一次后会缓存在 `HF_HOME`(默认 `~/.cache/huggingface`)。
用 `uv run laya-mcp doctor` 确认 `包含 laya 检查点: True`,之后设置 `HF_HUB_OFFLINE=1` 即可离线。
</details>

<details>
<summary><b>多个 Agent 同时调用会不会打爆?</b></summary>

不会。模型是进程级单例,推理跑在有界线程池里,相同请求命中 LRU 缓存。
横向扩展只需多起几个进程/容器(每个进程会各自加载模型,注意 CPU/显存与内存占用)。
</details>

<details>
<summary><b>和直接调用 LLM 相比,什么时候该用它?</b></summary>

| 场景 | 用 Laya | 用 LLM |
| --- | --- | --- |
| 「走哪条路 / 是不是 X / 严重程度几级」 | ✅ | ❌ 太贵太慢 |
| 需要生成文本、写代码、长链推理 | ❌ | ✅ |
| 高频、对延迟敏感(<100ms) | ✅ | ❌ |
| 候选集封闭、要求可复现 | ✅ | ⚠️ 不稳定 |

实践上两者是**互补**关系:用 Laya 做**前置判断与分流**,只在 `grade != auto_execute` 时才唤起 LLM。
</details>

<details>
<summary><b>如何自定义问题集?(不写代码)</b></summary>

直接把 `questions` 内联传给 `decide` 即可,无需改代码:

```json
{
  "state": "...",
  "questions": {
    "priority": {
      "kind": "choice",
      "instructions": "这条需求应该排什么优先级?",
      "criteria": {
        "p0": "线上故障,立刻处理",
        "p1": "本周内必须完成",
        "p2": "可以排入下个迭代"
      }
    }
  }
}
```

需要长期复用时,在 [`src/laya_mcp/core/presets.py`](src/laya_mcp/core/presets.py) 的 `PRESETS` 中注册一个新条目。
</details>

<details>
<summary><b>怎么监控服务健康?</b></summary>

- `GET /v1/healthz` — 存活(模型失败也返回 200,`status=degraded`)
- `GET /v1/status` — 就绪、已加载 checkpoint、缓存、延迟分位
- `GET /v1/metrics` — 原始计数与延迟,可直接被抓取
- 所有响应带 `x-request-id` 与 `x-response-time-ms` 头
</details>

---

## 参考

- [Laya 模型(HuggingFace)](https://huggingface.co/convaiinnovations/laya)
- [Model Context Protocol 规范](https://modelcontextprotocol.io/)
- [FastAPI 文档](https://fastapi.tiangolo.com/)
- 原始最小示例:[`examples/quickstart_router.py`](examples/quickstart_router.py)

---

## 许可证

见仓库根目录 `LICENSE`。使用 Laya 权重时请同时遵守其 HuggingFace 页面上的许可条款。

TDQS

A3.8/5.0

Scored across 9 tools

Disambiguation4/5

Most tools have clearly distinct roles: decide variants differ by input shape (typed questions, single statement, many labels), while route_request, list_presets, describe_questions, and server tools are separate concerns. However, should_escalate and decide_yes_no both produce gate-like boolean verdicts, and an agent could confuse when to use one versus the other despite the descriptions.

Naming Consistency4/5

All names use snake_case, and most follow a predictable verb_noun or verb pattern (decide_yes_no, decide_shortlist, route_request, list_presets, describe_questions, preload_warmup). Minor deviations are should_escalate (modal verb phrase) and server_status (noun phrase), but these are still readable and consistent enough.

Tool Count5/5

Nine tools is well-scoped for a decision/inference server. The surface covers decision making, routing, preset introspection, and server health without excessive redundancy.

Completeness4/5

The set covers core decision workflows (single yes/no, typed questions, shortlisting), routing, preset listing/description, status, and warmup. Minor gaps include no explicit tool to inspect or configure checkpoints beyond status, and no direct way to answer multiple independent statements in one call, but these are workable around.

Maintenance

ActivityMaintained
ResponsivenessNo issues