深入 ACP 协议:将 Kiro CLI 封装为 REST API 的双通道架构实现
把一个只有 stdio 接口的 CLI 工具变成可编程的 REST API,核心难点不在 HTTP 层,而在于搞清楚底层协议的实际行为。
最近我在做 AI 编码工具的集成。Kiro CLI 是亚马逊云科技推出的终端 AI 编码工具,代码生成、分析、重构都支持。问题在于:它只有 stdio 接口,没有 HTTP 端口,没有 SDK。想在自动化流水线里用它,只能自己封装。
这篇文章记录了完整的封装过程。重点是 ACP 协议的通信机制、模型切换的限制分析,以及最终的双通道架构设计。
项目结构
最终只有两个文件,约 600 行 Python:
kiro-api/
├── acp_client.py # ACP JSON-RPC 2.0 客户端(~300 行,纯 stdlib)
└── server.py # FastAPI HTTP 服务(~280 行)
完整源码已在上文逐段展示,可直接复制使用。
ACP 协议详解
Kiro CLI 通过 kiro-cli acp 子命令暴露了 ACP(Agent Communication Protocol)协议。底层是 JSON-RPC 2.0 over stdio 的双向通信。
核心方法
| 方法 | 方向 | 语义 |
|---|---|---|
initialize |
Client → Kiro | 协议握手,声明客户端能力 |
session/new |
Client → Kiro | 创建编码会话,返回 session_id |
session/prompt |
Client → Kiro | 发送编码任务,阻塞等待结果 |
session/update |
Kiro → Client | 流式推送文本块(通知,无 id) |
session/request_permission |
Kiro → Client | 敏感操作审批请求(有 id,需回复) |
_kiro.dev/metadata |
Kiro → Client | Credits 和上下文用量(通知) |
协议中有两类消息需要区分:
- 通知(Notification):无 id 字段,单向推送,不需要回复
- 请求(Request):有 id 字段,需要回复
session/update 是通知,session/request_permission 是请求。搞混这两个会导致严重问题——权限请求不回复,进程就永远挂着。
请求-响应的异步特性
ACP 的 stdio 通信是异步的。你往 stdin 写一条请求之后,stdout 上不会直接给你响应。中间可能穿插多条 session/update 通知和 _kiro.dev/metadata 通知,最后才是你的响应。
这意味着:你不能简单地"写一行、读一行"。需要一个消息分发机制。
模型切换:8 种方式的系统性验证
我的需求是在 API 层支持动态指定模型。下面是系统性的验证过程:
| # | 尝试方式 | 结果 | 分析 |
|---|---|---|---|
| 1 | session/new 加 model 参数 |
忽略 | 协议不识别该字段 |
| 2 | session/setModel 方法 |
Method not found | 协议未定义 |
| 3 | session/configure 方法 |
Method not found | 协议未定义 |
| 4 | _kiro.dev/commands/execute/model |
进程崩溃 | 反序列化错误 |
| 5 | kiro-cli acp --model X |
unexpected argument | CLI 不支持该参数 |
| 6 | kiro-cli-chat acp --model X |
unexpected argument | CLI 不支持 |
| 7 | 环境变量 KIRO_MODEL |
忽略 | 不被识别 |
| 8 | ~/.kiro/settings/cli.json |
忽略 | 不影响运行时行为 |
结论:ACP v1.25 不支持运行时模型切换。 这是协议的设计限制。
替代方案:kiro-cli chat --model X --no-interactive。这个命令支持指定模型,但以一次性子进程运行,不支持多轮会话。
双通道架构
基于上述限制,设计了双通道架构:
HTTP 客户端(curl / Python / JS / 任意 HTTP 客户端)
|
| HTTP REST (port 8642)
v
FastAPI Server (server.py)
|
-----+--------------------
| |
| model=auto | model=指定模型
v v
ACP 通道 Chat 通道
acp_client.py server.py (subprocess)
常驻进程 一次性子进程
JSON-RPC 2.0 over stdio stdout 文本解析
多轮会话支持 单次调用
详细对比:
| 维度 | ACP 通道 | Chat 通道 |
|---|---|---|
| 实现位置 | acp_client.py | server.py |
| 通信协议 | JSON-RPC 2.0 over stdio | subprocess + 文本解析 |
| 进程模型 | 常驻进程 | 每次调用新建子进程 |
| 模型选择 | 不支持(固定 auto) | 支持 --model |
| 多轮会话 | 支持 | 不支持 |
| 冷启动 | 无 | 约 3 秒 |
| 稳定性 | 结构化协议,可靠 | 文本解析,较脆弱 |
设计原则:优先走 ACP 通道。只有在需要指定模型时才降级到 Chat 通道。
关键实现一:Event + Pending Map 同步机制
异步 stdio 转同步调用的核心代码:
class ACPClient:
def __init__(self):
self._pending = {} # {req_id: (Event, [result, error])}
def _send_request(self, method, params, timeout=60):
req_id = self._next_id()
event = threading.Event()
self._pending[req_id] = (event, [None, None])
msg = {"jsonrpc": "2.0", "id": req_id,
"method": method, "params": params}
self._proc.stdin.write(json.dumps(msg) + "\n")
self._proc.stdin.flush()
# 阻塞等待
if not event.wait(timeout):
raise TimeoutError(f"Request {method} timed out")
_, holder = self._pending.pop(req_id)
if holder[1]: # error
raise RPCError(holder[1])
return holder[0] # result
读线程负责分发:
def _read_loop(self):
for line in self._proc.stdout:
msg = json.loads(line)
if "id" in msg and "result" in msg:
# 响应:按 id 匹配并唤醒
if msg["id"] in self._pending:
event, holder = self._pending[msg["id"]]
holder[0] = msg["result"]
event.set()
elif "method" in msg:
# 通知或请求:按 method 分发
self._handle_notification(msg)
关键实现二:流式文本收集
session/prompt 的最终响应只有结束标志:
{"jsonrpc": "2.0", "id": 3, "result": {"stopReason": "end_turn"}}
文本内容通过 session/update 通知逐块到达:
← {"method": "session/update", "params": {"content": {"type": "text", "text": "```python"}}}
← {"method": "session/update", "params": {"content": {"type": "text", "text": "\ndef fibonacci(n):"}}}
← ...
← {"jsonrpc": "2.0", "id": 3, "result": {"stopReason": "end_turn"}}
处理方式:
self._session_updates = {} # {session_id: [text_chunk, ...]}
def _handle_session_update(self, params):
sid = params.get("sessionId")
content = params.get("content", {})
if content.get("type") == "text":
self._session_updates.setdefault(sid, []).append(content["text"])
def _get_collected_text(self, session_id):
chunks = self._session_updates.pop(session_id, [])
return "".join(chunks)
关键实现三:权限请求处理
权限请求是带 id 的同步 request,必须回复:
def _handle_notification(self, msg):
method = msg.get("method")
if method == "session/request_permission" and "id" in msg:
# 必须回复!否则 Kiro 永远阻塞
self._send_response(msg["id"], {"optionId": "allow_always"})
elif method == "session/update":
self._handle_session_update(msg.get("params", {}))
关键实现四:Chat 输出清洗
Chat 通道的 stdout 面向终端用户,混杂大量 UI 元素:
def _clean_chat_output(raw: str) -> str:
import re
# 1. 去 ANSI 转义码
text = re.sub(r'\x1b\[[0-9;]*[a-zA-Z]', '', raw)
# 2. 跳过 banner 和提示框
lines = text.split('\n')
content_start = None
content_end = None
for i, line in enumerate(lines):
if line.strip().startswith('>'):
content_start = i
break
for i in range(len(lines) - 1, -1, -1):
if '▸' in lines[i] and 'Credits' in lines[i]:
content_end = i
break
# 3. 提取有效内容
if content_start is not None:
end = content_end or len(lines)
return '\n'.join(lines[content_start:end])
return text
关键实现五:stderr 死锁防护
def _drain_stderr(self):
"""专门的线程排空 stderr,防止缓冲区满导致进程阻塞"""
for line in self._proc.stderr:
pass # 读掉即可
这个问题在 stdio 双向通信中很常见。不处理 stderr,高频输出时整个进程会死锁。
REST API 接口
| 接口 | 方法 | 说明 |
|---|---|---|
/prompt |
POST | 快捷调用,支持指定模型 |
/sessions |
POST | 创建多轮会话 |
/sessions/{id}/prompt |
POST | 会话内发送任务 |
/sessions |
GET | 列出活跃会话 |
/sessions/{id} |
DELETE | 删除会话 |
/models |
GET | 列出可用模型 |
/ |
GET | 健康检查 |
已知限制
| 限制 | 说明 |
|---|---|
| 无鉴权 | 仅限内网使用,不建议暴露公网 |
| 单 ACP 连接 | 高并发需排队处理 |
| 会话不持久化 | 服务重启后丢失 |
| Chat 无上下文 | 每次新进程 |
| 输出清洗脆弱 | 依赖 CLI 输出格式 |
总结
| 类别 | 结论 |
|---|---|
| 协议限制 | ACP v1.25 不支持运行时切换模型 |
| 架构决策 | 双通道:多轮走 ACP,指定模型走 Chat |
| 关键发现 | session/update 通知才是文本载体 |
| 关键发现 | 权限请求是同步阻塞 request |
| 关键发现 | Chat stdout 混杂 UI 元素,需清洗 |
| 关键发现 | stdio 必须读写线程分离,stderr 必须排空 |
把 CLI 工具封装成 API,核心工作量在于理解协议的真实行为。官方文档没覆盖的部分,只能靠调试和抓包。
完整源码已在上文逐段展示,可直接复制使用。
本文基于亚马逊云科技官方博客整理。原文:将 Kiro CLI 封装为 REST API:双通道架构实践

浙公网安备 33010602011771号