声明:本文为学习笔记与工程化延伸,核心脉络来自阿里云开发者技术号发布的《AI coding 智能体设计》,在此基础上给出一个“迷你版 AI coding CLI”骨架用于理解原理(不是生产级实现);如有出入,以原文与官方文档为准。原文链接见文末参考。

如果你想真正理解 AI coding 智能体为什么“能做事”,最好的方式不是再看 10 篇概念文,而是亲手做一个“玩具”:

  • 支持 /命令(清空/压缩/帮助等)
  • 支持 @路径(把文件作为上下文喂给模型)
  • 支持工具注册与工具调用闭环(tool-calling loop)
  • 有最小护栏(权限/超时/输出裁剪/可观测)

这篇就给你一个最小可运行骨架(填上你的 OpenAI 兼容接口即可跑)。


01|先定输入协议:两条规则就够

  1. 输入以 / 开头:当作命令(不是自然语言)
  2. 输入包含 @path:在发给模型前先展开,把文件内容拼到上下文

我们先不追求完美解析,先让闭环跑起来。


02|最小工具集:先只做 3 个只读工具

强烈建议从只读开始(安全、可控、收益大):

  • read_file(path):读取文件(限制在项目目录、限制大小)
  • glob(pattern):按模式找文件
  • grep(pattern, path):在文件内搜索(限制输出行数)

写操作、Shell 执行等,等闭环稳定后再加。


03|核心:tool-calling loop(闭环)

闭环的本质就一句话:
模型提出工具调用意图 → 系统执行 → 把输出回填 → 模型继续推理

下面这份 Python 骨架用 OpenAI 兼容的 tool-calling 写法示意(你可以替换为任意兼容实现)。

注意:这是“教学版最小骨架”,省略了很多工程细节;生产化需要更严格的权限与审计。

import json
import os
import re
from pathlib import Path
from typing import Any, Dict, List, Tuple

# 你可以替换为任意 OpenAI 兼容 SDK
try:
    from openai import OpenAI
except Exception:
    OpenAI = None  # 仅用于展示骨架


PROJECT_ROOT = Path(os.getcwd()).resolve()
MAX_FILE_BYTES = 64 * 1024  # 64KB,教学用
MAX_TOOL_OUTPUT_CHARS = 4000


def clamp_text(s: str, limit: int = MAX_TOOL_OUTPUT_CHARS) -> str:
    if len(s) <= limit:
        return s
    return s[:limit] + "\n...[TRUNCATED]..."


def safe_resolve(path_str: str) -> Path:
    p = (PROJECT_ROOT / path_str).resolve()
    if not str(p).startswith(str(PROJECT_ROOT)):
        raise ValueError("path out of project root")
    return p


def tool_read_file(path: str) -> str:
    p = safe_resolve(path)
    if not p.exists() or not p.is_file():
        return f"[read_file] not found: {path}"
    if p.stat().st_size > MAX_FILE_BYTES:
        return f"[read_file] file too large ({p.stat().st_size} bytes): {path}"
    return p.read_text(encoding="utf-8", errors="replace")


def tool_glob(pattern: str) -> str:
    # 简化:只允许相对路径模式
    matches = sorted([str(p.relative_to(PROJECT_ROOT)) for p in PROJECT_ROOT.glob(pattern)])
    return "\n".join(matches[:200]) or "[glob] no matches"


def tool_grep(pattern: str, path: str = ".") -> str:
    base = safe_resolve(path)
    files: List[Path] = []
    if base.is_file():
        files = [base]
    else:
        files = [p for p in base.rglob("*") if p.is_file()]
    out: List[str] = []
    rx = re.compile(pattern)
    for f in files:
        try:
            text = f.read_text(encoding="utf-8", errors="replace")
        except Exception:
            continue
        for i, line in enumerate(text.splitlines(), 1):
            if rx.search(line):
                rel = str(f.relative_to(PROJECT_ROOT))
                out.append(f"{rel}:{i}:{line}")
                if len(out) >= 200:
                    return "\n".join(out) + "\n...[TRUNCATED]..."
    return "\n".join(out) or "[grep] no matches"


TOOLS = {
    "read_file": tool_read_file,
    "glob": tool_glob,
    "grep": tool_grep,
}


def expand_at_paths(text: str) -> Tuple[str, List[str]]:
    # 识别 @path(最小实现:以空格分隔)
    tokens = text.split()
    collected: List[str] = []
    for t in tokens:
        if t.startswith("@") and len(t) > 1:
            path = t[1:]
            collected.append(path)
    if not collected:
        return text, []

    blocks = []
    for p in collected:
        content = tool_read_file(p)
        blocks.append(f"\n\n[CONTEXT:{p}]\n{content}\n")
    expanded = text + "\n" + "\n".join(blocks)
    return expanded, collected


def build_tool_schemas() -> List[Dict[str, Any]]:
    # OpenAI tool schema(示意)
    return [
        {
            "type": "function",
            "function": {
                "name": "read_file",
                "description": "Read a text file from project root (read-only).",
                "parameters": {
                    "type": "object",
                    "properties": {"path": {"type": "string"}},
                    "required": ["path"],
                },
            },
        },
        {
            "type": "function",
            "function": {
                "name": "glob",
                "description": "List files by glob pattern under project root (read-only).",
                "parameters": {
                    "type": "object",
                    "properties": {"pattern": {"type": "string"}},
                    "required": ["pattern"],
                },
            },
        },
        {
            "type": "function",
            "function": {
                "name": "grep",
                "description": "Search pattern in files under a path (read-only).",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "pattern": {"type": "string"},
                        "path": {"type": "string"},
                    },
                    "required": ["pattern"],
                },
            },
        },
    ]


def run_tool_call(name: str, args: Dict[str, Any]) -> str:
    if name not in TOOLS:
        return f"[tool] unknown: {name}"
    try:
        if name == "read_file":
            out = TOOLS[name](args["path"])
        elif name == "glob":
            out = TOOLS[name](args["pattern"])
        elif name == "grep":
            out = TOOLS[name](args["pattern"], args.get("path", "."))
        else:
            out = "[tool] not implemented"
        return clamp_text(out)
    except Exception as e:
        return f"[tool] error: {e}"


def chat_loop(api_key: str, base_url: str, model: str) -> None:
    if OpenAI is None:
        raise RuntimeError("Please install openai python sdk: pip install openai")

    client = OpenAI(api_key=api_key, base_url=base_url)
    tools = build_tool_schemas()

    messages: List[Dict[str, Any]] = []

    print("Mini Agent CLI. Type /clear to reset, /exit to quit. Use @path to include files.")
    while True:
        user_in = input("\n> ").strip()
        if not user_in:
            continue
        if user_in == "/exit":
            break
        if user_in == "/clear":
            messages = []
            print("[state] cleared")
            continue

        expanded, at_paths = expand_at_paths(user_in)
        if at_paths:
            print(f"[context] included: {', '.join(at_paths)}")

        messages.append({"role": "user", "content": expanded})

        # tool-calling loop
        for _ in range(8):  # 防止死循环
            resp = client.chat.completions.create(
                model=model,
                messages=messages,
                tools=tools,
            )

            msg = resp.choices[0].message
            # tool call?
            if getattr(msg, "tool_calls", None):
                messages.append(msg)
                for tc in msg.tool_calls:
                    name = tc.function.name
                    args = json.loads(tc.function.arguments or "{}")
                    out = run_tool_call(name, args)
                    messages.append(
                        {"role": "tool", "tool_call_id": tc.id, "content": out}
                    )
                continue

            # final
            content = msg.content or ""
            print("\n" + content)
            messages.append({"role": "assistant", "content": content})
            break


if __name__ == "__main__":
    # 填上你的兼容配置即可运行
    # api_key = os.getenv("OPENAI_API_KEY", "")
    # base_url = os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1")
    # model = os.getenv("OPENAI_MODEL", "gpt-4.1-mini")
    # chat_loop(api_key, base_url, model)
    print("This is a skeleton. Set OPENAI_API_KEY/OPENAI_BASE_URL/OPENAI_MODEL and call chat_loop().")

04|把它变得“更像真实产品”:四个你一定会补的工程点

  1. 输出裁剪策略:不要把超长日志原样回填,先截断/摘要
  2. 权限与白名单:默认只读;写操作需要确认点或白名单路径
  3. 可观测性:记录每次 tool call 的 name/args/耗时/输出长度/错误原因
  4. 失败恢复:重试上限、降级路径(例如从 grep 降级为 glob+read_file)

当这四件事补齐,你基本就能复刻大多数 AI coding 工具的“核心体验”。


05|系列导航

  • 系列 01:从 Chat 到 Agent:4 个关键零件
  • 系列 02:命令系统:从提示词模板到可扩展子命令
  • 系列 03:@路径上下文:如何给材料而不喂爆上下文
  • 系列 04:MCP 与工具闭环:注册、调用、回填与失败恢复
  • 系列 05:上下文治理:清空/压缩/摘要与预算控制
  • 系列 06:SubAgent:上下文隔离与模块化协作
  • 系列 07:规约驱动:让交付可复现的 Spec 工作流
  • 系列 08(本文):迷你 CLI:从伪代码到最小可运行骨架

参考与致谢

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐