1403 字
7 分钟
给 Factorio 写了个离线 Agent Skill,顺手踩了几个坑

项目叫 factorio-docs-mcp,顾名思义,原本是给 Factorio Lua API 做的 MCP server。打开看了一下——src 目录里除了一个空 __init__.py,什么都没有。

那就干脆改成 Skill 重新来过。

为什么不做 MCP#

之前写过一篇 MCP vs Skills 的对比,这里不重复理论,说说这个具体需求为什么选 Skill:

MCP 的核心优势是进程隔离——每个 server 独立跑,OAuth、凭证、副作用都在沙箱里。但 Factorio 文档查询根本没有副作用,也不需要凭证,不需要实时数据。它就是”给 agent 塞一堆文档,让它能搜”。

这种需求用 MCP 等于杀鸡用牛刀:多一个常驻进程、多一套 JSON-RPC 协议栈、多一份部署配置——全是开销,没有收益。

Skill 反而刚好:一个目录、一个 SKILL.md、几个 Python 脚本。Agent 需要时加载,不需要时零开销。

不要动态 fetch#

第一版的 search_docs.py 是运行时直接抓 Factorio 官网的 API JSON。被点名:

“no network at query time,需要确保 up to date 啊”

两个需求看起来矛盾——查询不联网,但还要跟得上 Factorio 版本更新。

解法是拆开:

  • 构建阶段(一次性):拉 runtime-api.json,建本地索引
  • 查询阶段(每次):打本地索引,零网络

更新时跑一个 update.py,它会拿远端 JSON 的 application_version 字段和本地缓存比较,版本一致就直接退出,不重建。Factorio 每次大版本更新才需要跑一次,平时查询完全离线。

scripts/update.py(核心逻辑)
cached = _cached_version() # 读本地 JSON 里的 application_version
data = _fetch() # 拉远端 JSON
if cached == remote_ver:
print(f"Already up to date (Factorio {remote_ver})")
return
# 版本不一致才写盘 + 重建索引
API_JSON.write_text(json.dumps(data, ...))
_rebuild()

为什么用 SQLite FTS5 而不是向量库#

建本地 RAG 的第一反应通常是:embedding → 向量数据库 → 语义搜索。但这个场景有几个特点让我决定用 SQLite FTS5:

查的是 API,不是语义。开发者查文档通常知道大致的类名或方法名,搜 inventory insertentity died,不是搜”怎么把东西放进箱子里”。这种查询模式更接近关键词检索,FTS5 的 BM25 排序已经够用。

零依赖。SQLite 是 Python 标准库。不需要装 chromadbfaisssentence-transformers,也不需要 GPU 或 embedding API。

porter stemming 内置。FTS5 支持 tokenize='porter ascii'insert 能匹配 insertinginserted,词形变化免费处理。

实际效果测下来:搜 circuit network read signal 精准返回相关的 control behavior 属性,搜 player built 首条就是 on_built_entity。对于文档检索这种场景,BM25 不比余弦相似度差。

CREATE VIRTUAL TABLE docs USING fts5(
doc_id UNINDEXED,
kind,
parent,
name,
signature,
body,
tokenize = 'porter ascii'
);

4195 条文档(148 个类、960 个方法/属性、219 个事件),索引 1.7 MB,构建约 1 秒。

takes_table:坑最深的签名细节#

Factorio API 有个 Lua 特有的调用约定——takes_table。960 个方法里有 108 个是这种形式:

-- takes_table = false,普通位置参数
entity.die(cause, force)
-- takes_table = true,必须传具名参数表
script.raise_biter_base_built{entity = some_entity}
-- 等价于
script.raise_biter_base_built({entity = some_entity})

原来的实现把两种方式都当成位置参数展示。写 mod 的时候如果照着签名抄,takes_table = true 的方法会直接出错。

修法是在索引构建时区分:

def _method_sig(cname, mname, params, rvs, fmt):
parts = [f"{p['name']}{'?' if p.get('optional') else ''}: {_type_str(p.get('type'))}"
for p in params]
if fmt.get("takes_table"):
inner = ", ".join(parts)
table_opt = "?" if fmt.get("table_optional") else ""
params_str = f"{{{inner}}}{table_opt}" # {key: type, opt?: type}?
else:
params_str = ", ".join(parts)
...

现在 LuaEntity:destroy 的签名是:

LuaEntity:destroy({do_cliff_correction?: boolean, raise_destroy?: boolean}?)

{...}? 表示整个参数表可省略——因为 table_optional = true,直接 entity.destroy() 也合法。返回值加了 ? 标记 optional,多返回值也正确展开。

SKILL.md 的 !#

Skills 的一个冷门特性:在 SKILL.md 里用 ```! 开头的代码块,内容会在 skill 加载时自动执行,输出注入到 context。

```!
python3 ${CLAUDE_SKILL_DIR}/scripts/build_index.py
`${CLAUDE_SKILL_DIR}` 是 Claude Code 注入的变量,指向 skill 目录的绝对路径,不管从哪个工作目录加载都能找到脚本。
加载 skill 时,agent 看到的不是"有个命令要跑",而是直接看到执行结果:

Index already exists at /Users/xxx/.claude/skills/factorio-docs/references/docs.db. Use —force to rebuild.

db 存在就跳过,缺失时从本地 JSON 重建——幂等,不会重复构建,也不联网。用户什么都不用做,skill 自己确认自己可用。
需要注意的是第一次踩坑:用了 `python` 而不是 `python3`,macOS 上 `python` 命令不在 PATH,导致 skill 加载报错。改成 `python3` 才正常。顺便整个项目的文档也统一成了 `python3`。
:::note
`!` 块是预处理,不是 Claude 执行的——Claude 看到的已经是替换后的结果。`allowed-tools: Bash` 控制的是 skill **体内**给 Claude 预授权的工具,和 `!` 块是两回事。
:::
## E2E 测试
31 个测试覆盖完整的 CLI 路径:
- **build_index**:跳过(db 存在)、重建(--force)、缺失 JSON 时退出
- **update**:版本相同跳过、上报版本号
- **search**:全文搜索、kind 过滤、精确查找、parent 成员列表
- **边界**:空查询、特殊字符、--top 截断、缺失 db 时的错误提示
```bash
~/miniconda3/bin/python -m pytest tests/test_e2e.py -v
# 31 passed in 0.79s

值得一提的是大小写不敏感的测试:FTS5 的 porter+ascii tokenizer 自动折叠大小写,--exactCOLLATE NOCASE,两条路都覆盖了,但测试不能拿 stdout 直接比——header 行包含原始 query,大小写不同 stdout 就不同。应该只比较结果条目:

def test_exact_case_insensitive(self):
lower = run(SEARCH, "--exact", "luaentity")
upper = run(SEARCH, "--exact", "LuaEntity")
# 只对比结果行,header 行(含原始 query)允许不同
lower_entries = [l for l in lower.stdout.splitlines() if l.startswith("[")]
upper_entries = [l for l in upper.stdout.splitlines() if l.startswith("[")]
assert lower_entries == upper_entries

最后项目在 StevenLi-phoenix/factorio-docs-skill,有写 Factorio mod 需求的可以直接 clone 到 ~/.claude/skills/factorio-docs/

StevenLi-phoenix
/
factorio-docs-skill
Waiting for api.github.com...
00K
0K
0K
Waiting...
给 Factorio 写了个离线 Agent Skill,顺手踩了几个坑
https://blog.lishuyu.top/posts/factorio-docs-skill-local-rag/
作者
猫猫魔女
发布于
2026-04-29
许可协议
CC BY-NC-SA 4.0