# 参考实现说明 · 零依赖服务器

> `serve.ps1` 的完整实现说明。975 行 PowerShell，无 Node、无 Python、无 .NET SDK、无任何第三方包。
> 对应知识库条目 [EG-101]、[EG-102]。

---

## 为什么用 PowerShell

这台机器上的环境探测结果：

```
node    → 不存在
npm     → 不存在
python  → 不存在
bun     → 不存在
deno    → 不存在
dotnet  → 6.0.400 SDK（但用它起 Web 服务需要建项目、还原包）
powershell → 5.1（Windows 内置）
```

选 PowerShell 的理由不是"它好"，而是**它证明了这套协议栈不依赖任何框架**。

`System.Net.HttpListener` 是 .NET Framework 内置类，Windows PowerShell 5.1 开箱可用。如果连它都能实现 MCP 服务端，那么 PHP、Go、Rust、Cloudflare Workers、Deno Deploy 都能——**核心逻辑不到 1000 行，且没有一行需要外部依赖**。

这正是 [EG-501] 误解 4 的实证：「MCP 需要装 SDK」是错的。

---

## 运行

```powershell
# 默认端口 4173
powershell -ExecutionPolicy Bypass -File serve.ps1

# 指定端口
powershell -ExecutionPolicy Bypass -File serve.ps1 -Port 8080
```

启动后：

```
  [kb] 已加载 15 条知识 · v1.2.0
  Evergreen · AI Context Engine
  ─────────────────────────────────────────────────────────
  站点    http://localhost:4173/
  入口    http://localhost:4173/llms.txt
  上下文  http://localhost:4173/api/context?topic=token&budget=2000
  MCP     http://localhost:4173/mcp   (POST, JSON-RPC 2.0)
  A2A     http://localhost:4173/.well-known/agent-card.json
  ─────────────────────────────────────────────────────────
  Ctrl+C 停止
```

**如果报「无法监听端口」**：Windows 上非管理员账户绑定 `http://localhost:port/` 前缀可能被 http.sys 拒绝。两种解法：

```powershell
# 方案 A：换高位端口
powershell -ExecutionPolicy Bypass -File serve.ps1 -Port 8080

# 方案 B：以管理员身份运行一次，注册 URL 保留（永久生效）
netsh http add urlacl url=http://localhost:4173/ user=Everyone
```

---

## ⚠️ Windows PowerShell 5.1 陷阱清单（8 条）

这是本次实现中**最容易踩、且症状最诡异**的坑，单独列一节。其中前 5 条是编码类，后 3 条是语言运行时类。

| # | 陷阱 | 症状 | 会不会报错 |
|---|---|---|---|
| 1 | `.ps1` 必须 UTF-8 带 BOM | 中文字面量语法错 | ✅ 报错 |
| 2 | `Request.QueryString` 用 ANSI 解码 | 中文查询参数 mojibake | ❌ 不报错 |
| 3 | `ConvertTo-Json` 三个坑 | 空数组变 `""`、单元素变对象、中文变 `\uXXXX` | ❌ 不报错 |
| 4 | HEAD 请求写正文 | 所有 HEAD 返回 500 | ✅ 报错 |
| 5 | 控制台中文输出乱码 | 终端显示方块 | ❌ 不报错 |
| 6 | `@(<List[object]>)` | MCP 工具整个不可用，只有 `-32603` | ✅ 报错（但无上下文） |
| 7 | 数组上调不存在的方法 | 变成逐元素调用，报 `does not contain a method` | ✅ 报错 |
| 8 | `$req.ContentEncoding` 退化为 ANSI | POST body 中文全乱 | ❌ 不报错 |

**其中 2 / 3 / 5 / 8 四条不报错**，只表现为“能跑但结果不对”，必须靠主动发中文请求 + 目测回显才能发现。`_verify.ps1` 就是为此而存在的。

### 陷阱 1 · .ps1 文件必须是 UTF-8 **带 BOM**

**症状**：脚本里所有中文字符串字面量报语法错误，例如：

```
ERR line 313: 字符串缺少终止符
ERR line 318: 缺少 ")" 
ERR line 340: 表达式或语句中包含意外的标记
```

一次能报出 20+ 处错误，看起来像整个文件结构崩了。

**根因**：Windows PowerShell 5.1 读取 `.ps1` 时，如果文件**没有 BOM**，会按**系统 ANSI 代码页**（简体中文系统是 GBK / CP936）解码。而编辑器与多数写文件工具默认输出 **UTF-8 无 BOM**。UTF-8 的中文字节被当 GBK 解析后，会产生意外的字节组合，其中某些组合恰好"吃掉"了后面的引号或括号 → 语法错误。

**修复**：把文件重存为 UTF-8 带 BOM。仓库里带了工具脚本：

```powershell
powershell -ExecutionPolicy Bypass -File _fixbom.ps1
# serve.ps1 -> BOM bytes: 239,187,191
```

`_fixbom.ps1` 的内容：

```powershell
$targets = @('serve.ps1', '_verify.ps1', '_check.ps1')
$dir = Split-Path -Parent $MyInvocation.MyCommand.Path
foreach ($t in $targets) {
    $p = Join-Path $dir $t
    if (-not (Test-Path $p)) { Write-Host "skip (missing): $t"; continue }
    $text = [System.IO.File]::ReadAllText($p, [System.Text.UTF8Encoding]::new($false))
    [System.IO.File]::WriteAllText($p, $text, [System.Text.UTF8Encoding]::new($true))
    $b = [System.IO.File]::ReadAllBytes($p)[0..2]
    Write-Host "$t -> BOM bytes: $($b -join ',')"
}
```

**纪律：每次编辑 `serve.ps1` 后都必须重跑 `_fixbom.ps1`。** 大多数编辑器保存时会去掉 BOM。

**验证语法**：

```powershell
powershell -ExecutionPolicy Bypass -File _check.ps1
# SYNTAX OK
```

`_check.ps1` 用 `[System.Management.Automation.Language.Parser]::ParseFile` 做静态解析，比"跑一遍看报什么错"快得多，且不会真的启动监听。

> PowerShell 7+（`pwsh`）没有这个问题——它默认按 UTF-8 读取无 BOM 文件。但 Windows 自带的仍然是 5.1，所以参考实现必须兼容 5.1。

### 陷阱 2 · `Request.QueryString` 用 ANSI 解码百分号转义

**症状**：中文查询参数变成 mojibake，检索质量彻底崩坏：

```
请求：/api/context?topic=%E5%B9%BB%E8%A7%89%20%E5%BC%95%E7%94%A8
响应：{"query":{"topic":"骞昏 寮曠敤", ...}}        ← UTF-8 字节被当 GBK 解
期望：{"query":{"topic":"幻觉 引用", ...}}
```

**根因**：`HttpListenerRequest.QueryString` 内部用系统默认代码页解码 `%XX` 序列，而不是 UTF-8。这是 .NET Framework 的历史行为。

**修复**：绕开 `QueryString`，从 `RawUrl` 自己解析：

```powershell
function Get-RawRequestParts {
    param($Req)
    $raw = [string]$Req.RawUrl
    if ([string]::IsNullOrEmpty($raw)) { $raw = '/' }
    $qi = $raw.IndexOf('?')
    $path  = if ($qi -ge 0) { $raw.Substring(0, $qi) } else { $raw }
    $query = if ($qi -ge 0) { $raw.Substring($qi + 1) } else { '' }
    return @{
        Path   = [System.Uri]::UnescapeDataString($path)   # 按 UTF-8 解码
        Query  = (Get-QueryTable $query)
    }
}

function Get-QueryTable {
    param([string]$Query)
    $table = @{}
    if ([string]::IsNullOrEmpty($Query)) { return $table }
    foreach ($pair in $Query.Split('&')) {
        if ([string]::IsNullOrEmpty($pair)) { continue }
        $eq = $pair.IndexOf('=')
        if ($eq -lt 0) {
            $table[[System.Uri]::UnescapeDataString($pair)] = ''
        } else {
            $k = [System.Uri]::UnescapeDataString($pair.Substring(0, $eq))
            $v = [System.Uri]::UnescapeDataString($pair.Substring($eq + 1).Replace('+', ' '))
            $table[$k] = $v
        }
    }
    return $table
}
```

`[System.Uri]::UnescapeDataString` 始终按 UTF-8 解码，这是关键。

**这个 bug 的危险之处在于它不报错。** 服务器返回 200，JSON 结构完全正确，检索算法照常运行——只是**搜的是乱码**。中文查询的召回率会掉到接近零，而日志里看不出任何异常。必须实际发一个中文查询并检查响应才能发现。

### 陷阱 3 · `ConvertTo-Json` 的三个坑

PowerShell 5.1 的 `ConvertTo-Json` 有三个行为会直接产出错误 JSON：

| 坑 | 表现 |
|---|---|
| 空数组 | `@()` → `""`（空字符串，不是 `[]`） |
| 单元素数组 | `@($x)` → `$x`（对象，不是 `[$x]`） |
| 非 ASCII | 中文 → `\u4e2d\u6587`（合法但对 AI 不友好，且体积翻 6 倍） |

第二个尤其致命：`entries` 数组只命中一条时，返回的是对象而非数组，**所有客户端的反序列化都会崩**。

**修复**：自己写序列化器 `ConvertTo-JsonLite`（约 45 行），强制保持数组结构、直接输出 UTF-8 中文、数字用 `InvariantCulture` 格式化（避免德语等区域设置把 `0.95` 写成 `0,95`）。

```powershell
$Inv = [System.Globalization.CultureInfo]::InvariantCulture
```

**这是"零依赖"的真实代价**：省下了 npm install，但要自己实现语言运行时本该提供的东西。在生产环境里，用 Node/Go/Python 会更省事——这个参考实现的价值是**说明协议逻辑本身有多简单**，不是推荐技术栈。

### 陷阱 4 · HEAD 请求写正文会 500

**症状**：所有 HEAD 请求返回 500：

```
Exception calling "CopyTo" with "1" argument(s):
"Bytes to be written to the stream exceed the Content-Length bytes size specified."
```

**根因**：`HttpListener` 在 HEAD 请求下不允许写响应正文，但 `ContentLength64` 已设置。调用 `OutputStream.Write` 会抛异常。

**修复**：发送前判断方法：

```powershell
# Send-Response
$r.ContentLength64 = $bytes.Length
if ($Ctx.Request.HttpMethod.ToUpperInvariant() -ne 'HEAD') {
    $r.OutputStream.Write($bytes, 0, $bytes.Length)
}
$r.Close()

# Send-File
$r.ContentLength64 = $fi.Length
if ($Ctx.Request.HttpMethod.ToUpperInvariant() -eq 'HEAD') { $r.Close(); return }
$fs = [System.IO.File]::OpenRead($FullPath)
try { $fs.CopyTo($r.OutputStream) } finally { $fs.Dispose(); $r.Close() }
```

**为什么必须支持 HEAD**：健康检查、负载均衡探针、`curl -I`、部分爬虫的预检都用 HEAD。HEAD 挂掉的服务器在监控里会显示为"不健康"。

### 陷阱 5 · 控制台中文输出乱码

**症状**：终端里中文显示为方块或问号。

**修复**：脚本开头设置：

```powershell
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
```

这只影响 `Write-Host` 的显示，不影响 HTTP 响应（响应始终显式用 UTF-8 字节写入）。

### 陷阱 6 · `@(<List[object]>)` 直接抛异常

**症状**：MCP 的 `search_context` 工具**整个不可用**，客户端只看到一句：

```
{"jsonrpc":"2.0","id":1,"error":{"code":-32603,"message":"Argument types do not match"}}
```

没有堆栈、没有行号、没有任何上下文。`GET /api/context` 完全正常，所以很容易误判为「MCP 层的问题」。

**根因**：Windows PowerShell 5.1 下，把 `System.Collections.Generic.List[object]` 交给一元数组操作符 `@()` 会抛 `ArgumentException：参数类型不匹配`：

```powershell
$l = New-Object System.Collections.Generic.List[object]
$x = @($l)          # ← 抛异常。即使 $l 是空的也抛
```

用 16 个样本二分后的完整结论：

| 写法 | 结果 |
|---|---|
| `@(<List[object]>)` | ❌ ArgumentException（空列表也抛） |
| `@(<List[string]>)` | ✅ 正常 |
| `@(<List[psobject]>)` | ✅ 正常 |
| `@(<Object[]>)` | ✅ 正常 |
| `@(<ArrayList>)` | ✅ 正常 |
| `<List[object]>.ToArray()` | ✅ 正常 |
| `<List[object]> \| ForEach-Object { $_ }` | ✅ 正常 |
| `foreach ($i in <List[object]>)` | ✅ 正常 |
| `<List[object]>.Count` / `[0]` 索引 | ✅ 正常 |

只有 `@()` 这一个一元操作符会炸。怀疑是 PS 5.1 的 `List[object]` 在转 `IEnumerable` 重载选择时歧义（`object` 元素类型同时匹配多个候选）。

**修复**：函数**不要交出活的 `List[object]`**，返回前就转成数组；消费端继续用 `@()`（此时已是 `Object[]`，安全）：

```powershell
# New-ContextBundle 返回前
$entryArr   = $entries.ToArray()      # List[object] -> Object[]
$citationArr = $citations.ToArray()
return [ordered]@{ entries = $entryArr; citations = $citationArr; ... }

# 消费端
$items = @($bundle.entries)           # ✅ 已是 Object[]
```

`ConvertTo-JsonLite` 按 `PSCustomObject → IDictionary → IEnumerable` 递序判定，`List[object]` 与 `Object[]` 的序列化结果完全相同，所以这个改动不影响 `/api/context` 的输出。

**为什么它能存活这么久**：`_verify.ps1` 当时只测了 `get_entry`，从未调过 `search_context`。现在四个工具全部进回归集。

### 陷阱 7 · 数组上调用不存在的方法会触发**成员枚举**

修完陷阱 6 之后很容易顺手在消费端也写 `.ToArray()`，然后得到：

```
[OrderedDictionary] does not contain a method named 'ToArray'
```

**根因**：PowerShell 在集合上找不到成员时，会对**每个元素**逐个调。`$bundle.entries` 已经是 `Object[]`，`Object[]` 本来就有 `.ToArray()`……但 PowerShell 的成员枚举优先展开，于是变成对 15 个 `OrderedDictionary` 条目分别调 `.ToArray()`。

**纪律**：一旦出参约定为数组，下游就只用 `@()` / `.Count` / 索引 / `foreach`，**不要再调任何看似集合的方法**。两个陷阱必须一起记：上游用 `.ToArray()`，下游用 `@()`。

### 陷阱 8 · `$req.ContentEncoding` 在请求没带 charset 时退化为 ANSI

**症状**：MCP 返回的中文全是 mojibake，而且是**双向乱码**（query 回显也乱）：

```
预期：# 检索结果：token 预算
实际：# 检索结果：token 棰勭畻

预期：topic「量子色动力学」在知识库中没有命中任何条目。
实际：topic「<9 个不相关生僻字>」在知识库中没有命中任何条目。
```

UTF-8 字节被当 GBK 读，3 个汉字变成 4～5 个生僻字。关键诊断信号是 **query 回显也乱**：它说明乱码发生在**请求解析**阶段，而不是响应编码阶段（后者只会让浏览器显示异常，服务端日志里仍是正常中文）。

**根因**：

```powershell
$reader = New-Object System.IO.StreamReader($req.InputStream, $req.ContentEncoding)
```

`HttpListenerRequest.ContentEncoding` 取自 `Content-Type` 的 `charset` 参数；**没带时它返回系统 ANSI 编码**（中文 Windows 上是 GBK / CP936），而不是 UTF-8。绝大多数 MCP 客户端发的就是不带 charset 的 `Content-Type: application/json`——因为 RFC 8259 规定 JSON 默认就是 UTF-8，写了反而冗余。

**修复**：自己读字节，默认 UTF-8，只在显式声明 charset 时覆盖：

```powershell
$ms = New-Object System.IO.MemoryStream
$req.InputStream.CopyTo($ms)
$rawBytes = $ms.ToArray()
$ms.Dispose()

$enc = [System.Text.Encoding]::UTF8
if (([string]$req.ContentType) -match 'charset=([\w\-\.]+)') {
    try { $enc = [System.Text.Encoding]::GetEncoding($Matches[1]) } catch { $enc = [System.Text.Encoding]::UTF8 }
}
$body = $enc.GetString($rawBytes)
if ($body.Length -gt 0 -and $body[0] -eq [char]0xFEFF) { $body = $body.Substring(1) }
```

末尾那句是剔 BOM：部分 Windows 客户端会带 `EF BB BF`，不剔则 `ConvertFrom-Json` 报「无效对象」。

**它和陷阱 2 是同一类问题的两个方向**：陷阱 2 是 URL 查询串被 ANSI 解码（GET），陷阱 8 是请求体被 ANSI 解码（POST）。两者都不报错，都只表现为检索质量归零。**验收时必须实际发一次中文请求并目测回显**，光看状态码和 JSON 结构发现不了。

---

## 架构

```
serve.ps1
│
├─ JSON 序列化层
│   ├─ Format-JsonString      字符串转义（保留中文原样）
│   └─ ConvertTo-JsonLite     递归序列化，保持数组结构
│
├─ 数据层
│   ├─ Get-KnowledgeBase      读 context.json，带进程内缓存
│   └─ Get-Metrics            读 metrics.json，带进程内缓存
│
├─ 检索引擎（与 assets/app.js 同构）
│   ├─ Get-TokenEstimate      CJK×1.0 + 其他×0.28
│   ├─ Get-Terms              拉丁词 + CJK 二元组，过滤停用词
│   ├─ Get-EntryScore         id 8 / title 5 / tags 4 / summary 3 / body min(n,6)
│   └─ New-ContextBundle      排序 + 贪心装箱 + truncated + citations + rules
│
├─ Prompt Pack
│   └─ Get-PromptPack         chat / coding / research / support
│
├─ MCP
│   ├─ Get-McpTools           4 个工具的 schema 声明
│   ├─ Invoke-McpTool         search_context / get_entry / get_prompt_pack / get_metrics
│   └─ Invoke-McpRequest      JSON-RPC 2.0 分派，支持批量与通知
│
├─ HTTP 响应
│   ├─ Send-Response          统一头（CORS / Link / Cache-Control / ETag），HEAD 安全
│   └─ Send-File              MIME 判定 + 304 + Link 头，HEAD 安全
│
├─ 路由
│   ├─ Resolve-StaticPath     路径穿越防护 + 目录 index.html
│   ├─ Get-RawRequestParts    UTF-8 安全的 URL 解析（见陷阱 2）
│   ├─ Get-QueryTable         UTF-8 安全的查询串解析
│   └─ Invoke-Route           端点分派
│
├─ 治理
│   └─ Test-RateLimit         120 req/min 滑动窗口
│
└─ 主循环
    └─ HttpListener.GetContext() → 限流 → 路由 → 日志 → 异常兜底
```

---

## 路由表

| 方法 | 路径 | 处理 | 响应类型 |
|---|---|---|---|
| `OPTIONS` | 任意 | CORS 预检 | 204 空体 |
| `GET`/`HEAD` | `/api/context` | `New-ContextBundle` | `application/json` |
| `GET`/`HEAD` | `/api/metrics` | 读 `metrics.json` | `application/json` |
| `GET`/`HEAD` | `/api/prompt-pack?scenario=` | `Get-PromptPack` | `text/plain` |
| `POST` | `/mcp` | `Invoke-McpRequest` | `application/json` |
| `GET`/`HEAD` | 其他 | `Resolve-StaticPath` → `Send-File` | 按扩展名 |
| — | 未命中 | 404 + **端点清单** | `text/plain` |

`/api/context` 与 `/mcp` 收到错误方法时返回 **405** 并说明正确方法，不是 404。

**404 响应体是端点清单**，不是空白页（下面逐字复制自 `serve.ps1`）：

```
404 — Not Found

请求的路径 /whatever 不存在。

Evergreen 的机读入口：
  /llms.txt                            站点摘要与文件索引（Agent 首选入口，实测 1,904 token）
  /llms-full.txt                       全量语境（实测 6,369 token，单次加载）
  /context.json                        结构化知识库
  /api/context?topic=<关键词>&budget=2000  预算化上下文检索
  /.well-known/agent-card.json         A2A v0.3 能力声明
  /mcp                                 MCP JSON-RPC 2.0 端点
  /metrics.json                        评测指标
```

这让走错路的 Agent 能**自我纠正**，而不是继续猜路径。实测能显著减少乱试次数。

---

## MIME 判定

常规扩展名查表，三处特判：

```powershell
if ($FullPath.EndsWith('.html.md')) { $mime = 'text/markdown; charset=utf-8' }
elseif ($name -eq 'llms.txt' -or $name -eq 'llms-full.txt') { $mime = 'text/markdown; charset=utf-8' }
elseif ($name -eq 'ai.txt') { $mime = 'text/plain; charset=utf-8' }
```

**为什么 `llms.txt` 要按 markdown 下发**：它的正文就是 Markdown（H1、blockquote、H2、链接列表）。标成 `text/plain` 会让部分 Agent 走纯文本分支，不做结构化解析，file list 的语义就丢了。

**为什么 `.html.md` 要单独判**：`GetExtension('/index.html.md')` 返回 `.md`，本应查表命中——但双扩展名容易在其他实现里被误判为 `.html`，显式处理更稳。

**`ai.txt` 保持 `text/plain`**：它是 ai.txt 提案的纯文本指令格式，不是 Markdown。

完整映射：

| 扩展名 | Content-Type |
|---|---|
| `.html` | `text/html; charset=utf-8` |
| `.md` / `.html.md` / `llms*.txt` | `text/markdown; charset=utf-8` |
| `.txt` / `.ps1` | `text/plain; charset=utf-8` |
| `.json` / `.map` | `application/json; charset=utf-8` |
| `.xml` | `application/xml; charset=utf-8` |
| `.js` | `application/javascript; charset=utf-8` |
| `.css` | `text/css; charset=utf-8` |
| `.svg` | `image/svg+xml` |
| `.woff` / `.woff2` | `font/woff` / `font/woff2` |
| 未知 | `application/octet-stream` |

---

## 响应头

`Send-Response` 统一下发：

```
Access-Control-Allow-Origin: *
Access-Control-Allow-Headers: Content-Type, Accept, If-None-Match
Access-Control-Allow-Methods: GET, POST, OPTIONS
X-Content-Type-Options: nosniff
Link: </llms.txt>; rel="describedby"; type="text/markdown"
Cache-Control: public, max-age=900        （或 no-store，用于 405/429/500）
ETag: <若提供>
```

`Send-File` 对 HTML 额外下发 llms.txt v2 要求的 alternate 链接：

```
Link: </index.html.md>; rel="alternate"; type="text/markdown", </llms.txt>; rel="describedby"
```

ETag 格式：

```powershell
$etag = '"{0}-{1}"' -f $fi.Length, $fi.LastWriteTimeUtc.Ticks
```

长度 + 最后写入时间（100ns 精度）。命中 `If-None-Match` 返回 **304** 且不带正文。

---

## 安全

**路径穿越防护**（`Resolve-StaticPath`）：

```powershell
$combined = [System.IO.Path]::GetFullPath((Join-Path $Root $p.TrimStart('/')))
$rootFull = [System.IO.Path]::GetFullPath($Root)
if (-not $combined.StartsWith($rootFull, [System.StringComparison]::OrdinalIgnoreCase)) { return $null }
```

先 `GetFullPath` 归一化（消解 `..`），再校验前缀。请求 `/../../windows/win.ini` 会归一化到 `$Root` 之外，被拒绝并返回 404。

`OrdinalIgnoreCase` 是 Windows 文件系统大小写不敏感所必需的；在 Linux 上应改为 `Ordinal`。

**其他**：
- 监听地址只有 `localhost` 与 `127.0.0.1`，**不绑定 0.0.0.0**——默认不对外网暴露
- 无写操作端点（`/mcp` 只读知识库）
- 无鉴权，因此不存任何凭据
- `X-Content-Type-Options: nosniff` 防止 MIME 嗅探

**要部署到公网**：不要直接暴露这个脚本。放到 nginx / Caddy / CDN 后面，静态部分由它们服务，动态端点用反向代理转发，或者按 [/docs/migration.md](/docs/migration.md) 用你的生产栈重写 `/api/context` 与 `/mcp`。

---

## 限流

```powershell
$script:Hits = New-Object System.Collections.Generic.Queue[datetime]

function Test-RateLimit {
    $now = [datetime]::UtcNow
    while ($script:Hits.Count -gt 0 -and ($now - $script:Hits.Peek()).TotalSeconds -gt 60) {
        [void]$script:Hits.Dequeue()
    }
    if ($script:Hits.Count -ge 120) { return $false }
    $script:Hits.Enqueue($now)
    return $true
}
```

滑动窗口：队列里保留最近 60 秒的时间戳，超过 120 个就拒绝。超限返回：

```
429 Too Many Requests
Retry-After: 60
{"error":"rate limit exceeded","limit":"120 req/min","retryAfter":60}
```

**局限**（生产环境需替换）：
- 全局窗口，不按 IP 区分——单个滥用者会影响所有人
- 进程内状态，重启即清零，多实例不共享
- `burst 300` 在 `ai.txt` 里声明了但代码未实现，实际硬上限是 120/min

这三点在 `/ai.txt` 与实现之间的差异应该被明确记录。本站选择保留声明值作为**目标**，并在 [/docs/changelog.md](/docs/changelog.md) 里标注为已知偏差。

---

## 异常兜底

主循环的 try/catch 保证任何异常都不会让监听器挂掉：

```powershell
try {
    while ($listener.IsListening) {
        $ctx = $listener.GetContext()
        $path = (Get-RawRequestParts -Req $ctx.Request).Path
        $meth = $ctx.Request.HttpMethod
        try {
            if (-not (Test-RateLimit)) { /* 429 */; continue }
            Invoke-Route -Ctx $ctx
            Write-Host "  $($ctx.Response.StatusCode) $meth $path" -ForegroundColor $color
        }
        catch {
            Write-Host "  500 $meth $path — $($_.Exception.Message)" -ForegroundColor Red
            try { Send-Response -Ctx $ctx -Body ('500 Internal Server Error: ' + $_.Exception.Message) -Status 500 -NoCache } catch {}
        }
    }
}
finally {
    $listener.Close()
}
```

内层 `catch` 里再套一层 `try`——因为向已中断的连接写响应本身也会抛异常。

`finally` 确保 Ctrl+C 时释放端口，否则 http.sys 会保留前缀几十秒导致重启失败。

---

## 前后端同构

**这是整个参考实现最重要的设计决策。**

同一套检索算法在两处各实现一次：

| | `serve.ps1` | `assets/app.js` |
|---|---|---|
| token 估算 | `Get-TokenEstimate` | `estTokens()` |
| 分词 | `Get-Terms` | `terms()` |
| 打分 | `Get-EntryScore` | `scoreEntry()` |
| 装箱 | `New-ContextBundle` | `buildContext()` |

**行为必须完全一致**：权重、停用词、CJK 范围、clamp 边界、排序规则、退化策略、小数位数，全部逐项对齐。

**为什么值得付这个代价**：

前端的所有交互（Playground、协议栈实时预览、知识库搜索）优先请求真实的 `/api/context`；请求失败（例如站点被部署到纯静态托管，没有后端）时**自动回退到 `buildContext()` 本地实现**，响应里 `source` 字段标为 `local-fallback`。

```javascript
async function queryContext({ topic, budget, category }) {
  try {
    const r = await fetch(`/api/context?${qs}`);
    if (!r.ok) throw new Error(String(r.status));
    return await r.json();               // source: "api/context"
  } catch (e) {
    return buildContext(state.kb, { topic, budget, category }, 'local-fallback');
  }
}
```

结果是：**同一份代码在有后端和无后端两种部署下表现一致**。用户看不出差别，Agent 也看不出差别。

代价是改算法要改两处。**改的时候必须同时改，并跑同一个查询比对两侧输出。**

---

## 测试

仓库自带的验收脚本用法（`_verify.ps1`）：

```powershell
powershell -ExecutionPolicy Bypass -File _verify.ps1
```

它检查四组：

1. **中文 query 解码** — `topic=幻觉 引用`，断言响应里 `query.topic` 是正确中文，且 EG-201 排第一
2. **CJK 混合查询** — `topic=token 预算&budget=600`，断言 EG-202 排第一
3. **Content-Type 矩阵** — 15 个路径逐一 HEAD，断言类型正确
4. **响应头** — `/` 的 `Link` / `Cache-Control` / `ETag`

手工快速验证：

```powershell
$b = 'http://127.0.0.1:4173'

# 端点存活
(Invoke-WebRequest "$b/llms.txt" -UseBasicParsing).StatusCode          # 200

# 中文检索（必须用 EscapeDataString，不能手拼）
$q = [System.Uri]::EscapeDataString('幻觉 引用')
$r = Invoke-WebRequest "$b/api/context?topic=$q&budget=1200" -UseBasicParsing
$raw = [System.Text.Encoding]::UTF8.GetString($r.RawContentStream.ToArray())
($raw | ConvertFrom-Json).query.topic                                  # 应输出「幻觉 引用」

# MCP
$body = '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
(Invoke-WebRequest "$b/mcp" -Method Post -Body $body -ContentType 'application/json' -UseBasicParsing).Content

# HEAD 不 500
(Invoke-WebRequest "$b/" -Method Head -UseBasicParsing).StatusCode     # 200
```

**注意**：`Invoke-WebRequest` 返回的 `.Content` 在 PowerShell 5.1 里可能已按错误编码解码。要检查原始字节，用 `[System.Text.Encoding]::UTF8.GetString($r.RawContentStream.ToArray())`。
