强军对决
106.2MB · 2026-03-22
在深入技术细节之前,你可以先花 1 分钟把 mini-cc 跑起来,亲手感受一下"自己的 Claude Code"是什么体验。
项目地址:github.com/Sunny-117/m… (欢迎 star)
# 安装 Ollama 并拉取模型(macOS 示例)
brew install ollama
ollama serve
ollama pull qwen2.5
# 一行命令安装 mini-cc
npm install -g mini-cc
# 启动!
mini-cc
启动后你会看到一个交互式终端:
它能读文件、写文件、精准编辑代码、搜索代码、执行命令——和 Claude Code 的核心能力一致,只不过跑在你本地的 Ollama 模型上,完全免费、完全私有。
你也可以用单次提问模式,不进入交互:
mini-cc ask "这个项目用了哪些依赖?"
想切换模型?设个环境变量就行:
MINI_CC_MODEL=qwen2.5:14b mini-cc
MINI_CC_MODEL=llama3.1 mini-cc
好,跑起来之后,我们来聊聊这个东西是怎么一步步做出来的。
用了大半年 AI 编程工具之后,我开始对一件事感到不安——我每天都在用 Claude Code、Cursor 这些工具,但我完全不知道它们是怎么工作的。
表面上看,这些工具很神奇:你告诉它"帮我加个登录功能",它就能自己读代码、改文件、跑测试,一连串操作行云流水。但仔细想想,这背后一定有一套精密的调度系统。大模型本身只会"生成文本",它是怎么做到自主决策"该读哪个文件""该改哪行代码""该执行什么命令"的?
我试过去看一些 Agent 框架的源码,发现它们动辄几万行代码,抽象层套抽象层,看得我头大。AutoGPT、MetaGPT 这类项目更是走了另一条路——架构复杂到让我怀疑,搞一个 Agent 真的需要这么多东西吗?
与其在别人的代码里迷路,不如自己造一个。
于是我开始了这个项目——mini-cc(Mini Claude Code)。目标很简单:用最少的代码,实现一个能读写文件、搜索代码、执行命令的 AI 编程助手。不用云端 API,完全基于本地 Ollama 模型,跑在命令行里。
在这个过程中,我经历了两个大版本的架构迭代。第一版用纯手写的 ReAct 循环,踩了无数坑之后推翻重来;第二版切换到 LangChain + LangGraph,才真正做出了一个可用的系统。
这篇文章,就是把这段从零到一的过程完整记录下来。不是教程,是实战复盘。
在动手之前,我先研究了 Agent 领域最基础的范式——ReAct(Reasoning + Acting)。这个概念来自 2022 年的一篇论文,核心思想极其简单:
说白了,就是一个 while 循环。模型在循环里不断"想→做→看→想→做→看",直到它决定输出最终答案。
这个思路太对我胃口了——简单、直接、可控。于是我决定用最原始的方式来实现它。
第一版的架构很直白:
用户输入 (CLI / readline)
↓
Commander 命令解析
↓
Agent ReAct 循环 (自建,纯手写)
↓ ↑
↓ ↑ <tool_call> / <tool_result> XML 交互
↓ ↑
Ollama 客户端 (ollama npm 包)
↓
deepseek-r1 (本地模型)
我选了 deepseek-r1 作为底座模型,因为它推理能力不错,而且有个很有意思的特性——它会在输出中用 <think> 标签包裹自己的思维过程。这天然适合 ReAct 模式中的 Thought 环节。
整个系统的灵魂在于系统提示词。我需要在 prompt 里告诉模型三件事:
工具描述是从 Tool 接口自动生成的。我定义了一个 buildSystemPrompt(tools) 函数,它遍历所有注册的工具,拼出这样的提示词:
## 可用工具
- **read_file**: 读取指定路径的文件内容。
参数:
- path (string, 必填): 要读取的文件路径
## 工具调用格式
<tool_call>
<name>工具名称</name>
<args>
{"参数名": "参数值"}
</args>
</tool_call>
模型看到这个提示词后,如果它认为需要调用工具,就按 XML 格式输出一段 <tool_call>;如果它觉得可以直接回答,就输出普通文本。
核心逻辑在 agent.ts 的 runAgent() 函数里:
// 伪代码,展示核心逻辑
async function runAgent(userMessage, history, callback) {
const messages = [systemPrompt, ...history, userMessage];
for (let i = 0; i < 15; i++) { // 最多 15 轮
const output = await ollama.chat(messages);
const parsed = parseModelOutput(output);
if (!parsed.toolCall) {
// 没有工具调用 → 最终回答
return parsed.text;
}
// 有工具调用 → 执行工具
const tool = toolMap.get(parsed.toolCall.name);
const result = await tool.execute(parsed.toolCall.args);
// 把模型输出和工具结果都追加到消息列表
messages.push({ role: "assistant", content: output });
messages.push({ role: "user", content: `<tool_result>${result}</tool_result>` });
// 继续循环 →
}
}
这就是一个最朴素的 ReAct 循环:调用模型 → 解析输出 → 有工具调用就执行 → 把结果喂回去 → 再调用模型。循环最多 15 轮,防止失控。
这是我花时间最多、也踩坑最多的部分。parser.ts 负责从模型的原始输出中提取结构化信息:
interface ParseResult {
thinking: string; // <think> 中的思维链
text: string; // 普通文本回复
toolCall: ParsedToolCall | null; // 解析出的工具调用
}
解析步骤:
<think>...</think> 标签(deepseek-r1 特有的思维链,不展示给用户)<tool_call><name>...</name><args>...</args></tool_call>听起来简单,实际上这是个噩梦。
第一版能跑起来,简单的任务(读个文件、列个目录)也能完成。但当我开始用它做稍微复杂一点的事情时,问题就开始井喷了。
问题一:XML 格式不稳定
模型是概率性的文本生成器,它不保证每次都输出规范的 XML。我遇到过各种变体:
</tool_call> 闭合标签<args> 里的 JSON 多了个逗号<tool_call> 写成 <tool_call > 多了个空格<tool_call>我的解析器不得不写得越来越"宽松"——先尝试严格 JSON.parse,失败了就尝试用正则提取 key-value,再失败就放弃工具调用当普通回答处理。整个解析逻辑越来越像一坨补丁代码。
问题二:HTML 内容写入是灾难
当用户让模型创建一个 HTML 文件时,模型需要在 <args> 的 JSON 里传入 HTML 内容。于是你会看到 XML 里面嵌套着 JSON,JSON 里面嵌套着 HTML,HTML 里面还有各种标签。解析器被折磨得死去活来——<div> 到底是 HTML 标签还是工具调用的一部分?XML 解析直接崩溃。
问题三:参数转义混乱
JSON 字符串里的引号需要转义,但模型经常忘记。一个包含双引号的文件内容,模型可能输出:
{"content": "console.log("hello")"}
JSON.parse 直接报错。我加了各种修复逻辑,但总有新的边界情况冒出来。
问题四:对话历史管理的心智负担
对话历史由 CLI 层手动维护——每次调用 runAgent() 要传入 history,函数返回后再把新消息 append 到 history。听起来不复杂,但加上工具调用的多轮循环后,消息列表的结构就变得很混乱:
[system, user1, assistant1(tool_call), user(tool_result), assistant2(tool_call), user(tool_result), assistant3(final)]
哪些消息该保留,哪些该丢弃,上下文窗口超了怎么截断……每个都是问题。
第一版让我深刻理解了一件事:ReAct 的思想是对的,但 prompt-based 的工具调用在工程上是不可靠的。它在 demo 里跑得很开心,但一碰到真实场景就碎了一地。
停下来做了一次冷静的复盘。第一版的问题不是某个 bug,而是整个方向的问题。
parser.ts 承担了太多不该承担的责任。一个正常的工具调用系统,参数解析应该是确定性的——输入是什么格式,输出就是什么结构。但 prompt-based 的方案完全依赖模型的"合作意愿",模型今天输出的格式和明天可能不一样。
我在解析器里写了十几个边界情况的处理逻辑,但每次换个模型或者换个任务场景,总有新的格式变体冒出来。这个模块永远改不完。
第一版的工具调度逻辑完全依赖提示词。我在 prompt 里写了"每次只调用一个工具",但模型不一定听。有时候它会在一段输出里塞两个 <tool_call>,有时候它会先输出一段分析,然后才输出工具调用,导致文本和 XML 混在一起。
更根本的问题是,模型对工具的认知完全来自提示词里的文本描述。如果某个工具的描述写得不够精确,模型就会误用。而提示词的调试成本极高——改一个字可能影响所有工具的调用行为。
ReAct 循环的每一步,状态都存在 messages 数组里。这个数组既是对话历史,又是 Agent 的"工作记忆",还是工具调用的上下文。所有信息混在一起,没有任何结构化管理。
当循环跑到第 5、6 轮时,消息列表已经很长了,模型的注意力开始分散,回答质量明显下降。但我没有好的办法去做消息的裁剪或摘要——因为所有消息都是平等的字符串,我分不清哪些重要哪些不重要。
出了问题怎么排查?只能在代码里加 console.log 打印模型输出和解析结果。没有结构化的日志,没有事件系统,没有断点调试的可能。当循环跑了 10 轮然后给出了一个错误的结果,我得手动翻几十行日志,一步步追踪模型的"思路"在哪一步跑偏了。
这些问题加在一起,让我意识到:自建 ReAct 循环是一个很好的学习项目,但它不是一个可维护的工程方案。我需要一个更靠谱的基础设施。
重构之前我评估了几个方案:
选 LangChain 的决定性因素是:它解决了我最大的痛点——模型原生 tool calling。
第一版之所以痛苦,根源在于 deepseek-r1 不支持 Ollama 的 tool calling API,我只能用 XML 格式在提示词层面"假装"有工具调用能力。但很多模型(比如 qwen2.5、llama3.1)是原生支持 tool calling 的——模型会在 API 响应中直接返回结构化的工具调用请求,参数是确定性的 JSON,不需要任何解析。
换模型 + 换框架 = 从根本上消灭解析问题。
用户输入 (CLI / readline)
↓
Commander 命令解析
↓
createReactAgent (LangGraph)
↓ ↑
↓ ↑ 原生 tool calling(结构化 JSON,不是 XML)
↓ ↑
ChatOllama (@langchain/ollama)
↓
qwen2.5 (本地模型,原生支持 tool calling)
对比第一版,变化是巨大的:
createReactAgent 自动编排Tool 接口 → LangChain tool() + Zod schemaMessage[] → LangGraph MemorySaver 自动管理ollama npm 包 → @langchain/ollama 的 ChatOllama第一版的 Agent 相关代码分散在三个文件里:
src/agent/
├── prompt.ts # 系统提示词生成(~80 行)
├── parser.ts # XML 输出解析器(~120 行)
└── agent.ts # ReAct 循环(~100 行)
第二版只剩一个文件:
src/agent/
└── agent.ts # LangGraph Agent 封装(~120 行)
parser.ts 被彻底删除——原生 tool calling 不需要解析。prompt.ts 也被删除——LangChain 根据 Zod schema 自动生成工具描述并注入提示词。系统提示词缩减为一个简单的字符串常量,只包含行为规则,不再需要拼接工具文档。
LangGraph 的 createReactAgent 让 Agent 的创建极其简洁:
const agent = createReactAgent({
llm: createLLM(), // ChatOllama 实例
tools, // LangChain tools 数组
prompt: SYSTEM_PROMPT, // 系统提示词
checkpointer, // MemorySaver,自动管理对话历史
});
这四行代码背后,LangGraph 自动创建了一个状态图(StateGraph),包含:
这正是我在第一版里手写的那个 for 循环,但 LangGraph 把它做得更优雅——用图的方式描述 Agent 的行为,每一步都是可追踪的事件。
第一版的工具定义用自定义 Tool 接口:
// 第一版:手工定义参数
interface Tool {
name: string;
description: string;
parameters: ToolParameter[];
execute: (args: Record<string, string>) => Promise<string>;
}
参数全是 string 类型,没有验证,工具描述要手动拼接到提示词里。
第二版用 LangChain 的 tool() 函数 + Zod schema:
// 第二版:Zod schema,类型安全
export const readFileTool = tool(
async ({ path: filePath }) => {
const resolved = path.resolve(process.cwd(), filePath);
return await fs.readFile(resolved, "utf-8");
},
{
name: "read_file",
description: "读取指定路径的文件内容",
schema: z.object({
path: z.string().describe("要读取的文件路径"),
}),
}
);
Zod schema 自动完成了三件事:参数类型校验、参数描述生成、工具文档注入到模型的 tool calling API。加一个新工具只需要写一个文件,然后在 index.ts 里 import 进来就行,不需要碰任何其他代码。
第二版用了 LangGraph 的 streamEvents API 来获取 Agent 执行过程中的实时事件:
const eventStream = agent.streamEvents(input, {
...config,
version: "v2",
});
for await (const event of eventStream) {
if (event.event === "on_chat_model_end") {
// 模型完成一次推理 → 检查是否有工具调用
}
if (event.event === "on_tool_end") {
// 工具执行完成 → 展示结果
}
}
每个事件都有明确的类型和结构化数据,不需要再从一坨文本里用正则提取信息。CLI 层通过回调函数实时展示进度:思考中 → 调用工具 → 工具结果 → 继续思考 → 最终回答。
两个版本跑完之后,我做了一个系统性的对比:
| 维度 | 第一版(手写 ReAct) | 第二版(LangChain + LangGraph) |
|---|---|---|
| 架构 | 单文件 for 循环 + XML 解析 | LangGraph 状态图,节点化编排 |
| 工具调用 | prompt-based XML 格式,不可靠 | 模型原生 tool calling,结构化 JSON |
| 可扩展性 | 加工具要改 prompt 拼接逻辑 | 加工具只需新建文件 + import |
| 调试能力 | console.log 打日志 | 结构化事件流,每步可追踪 |
| 对话历史 | 手动维护 Message[] | MemorySaver + threadId 自动管理 |
| 工程复杂度 | 低起步,高维护 | 中起步,低维护 |
| 代码量 | agent 三文件 ~300 行 | agent 单文件 ~120 行 |
| 模型要求 | 任何模型(只要能生成文本) | 需支持原生 tool calling |
我的结论:
工具系统是整个 Agent 最核心的模块,它的设计直接决定了 Agent 的能力边界和可靠性。
第一版:XML 解析 + 手动分发
模型输出文本 → 正则提取 <tool_call> → JSON.parse 提取参数 → toolMap.get(name) → tool.execute(args)
这条链路上每一步都可能出错。正则可能匹配失败,JSON 可能格式不对,工具名可能拼错。我在 parser.ts 里写了大量的 fallback 逻辑来应对各种变体,但本质上是在跟概率做斗争。
第二版:原生 tool calling + LangGraph 自动分发
模型返回 tool_calls 数组 → LangGraph ToolNode 自动执行 → 结果自动注入回模型
整条链路是确定性的。模型返回的 tool_calls 是结构化的 JSON 对象,参数是经过 Zod schema 校验的,工具执行由 LangGraph 的 ToolNode 自动完成。我不需要写任何分发逻辑。
一个关键的设计决策:edit_file 工具
在迭代过程中,我发现 write_file 工具有一个严重的缺陷:它是整体覆写文件。当模型要修改一个文件时,它需要先 read_file 读取全部内容,然后在回复中输出修改后的完整文件内容,再通过 write_file 写回去。
这对小模型来说几乎不可能做对——一个 200 行的文件,模型需要原封不动地复制 195 行不变的代码,只改其中 5 行。它经常会丢行、改错缩进、漏掉注释。
于是我参考 Claude Code 的做法,新增了 edit_file 工具——diff/patch 式编辑:
export const editFileTool = tool(
async ({ path: filePath, old_text, new_text }) => {
const content = await fs.readFile(resolved, "utf-8");
// 唯一性校验:old_text 必须在文件中只匹配一处
const occurrences = content.split(old_text).length - 1;
if (occurrences === 0) return "错误:未找到要替换的文本";
if (occurrences > 1) return "错误:找到多处匹配,请提供更多上下文";
const updated = content.replace(old_text, new_text);
await fs.writeFile(resolved, updated, "utf-8");
return `文件已编辑: ${filePath}`;
},
{
name: "edit_file",
description: "通过精准的文本替换来编辑文件...",
schema: z.object({
path: z.string(),
old_text: z.string().describe("要被替换的原始文本"),
new_text: z.string().describe("替换后的新文本"),
}),
}
);
核心设计:
old_text 必须在文件中只匹配一处,多处匹配则报错要求提供更多上下文。这保证了替换的精确性。系统提示词是 Agent 的"灵魂"。两个版本的提示词设计有本质区别。
第一版:重型提示词
第一版的提示词是一个巨大的模板,包含:
这个提示词有 1000+ token,而且非常脆弱——改一个措辞可能导致模型不再正确输出 XML 格式。我花了大量时间在"提示词调试"上,本质上是在做自然语言编程,没有类型系统,没有编译器,改了不知道会不会 break。
第二版:轻量提示词 + 系统保障
第二版的提示词大幅精简,因为很多工作不再需要通过提示词来完成:
const SYSTEM_PROMPT = `你是一个强大的代码助手 Agent...
当前工作目录: ${process.cwd()}
核心原则:
- 修改现有文件时,必须使用 edit_file 工具进行精准替换
- 当用户要求执行命令时,必须使用 run_command 工具
- 永远不要建议用户自己去终端手动操作
修改文件的正确流程:
1. 先用 read_file 读取文件完整内容
2. 使用 edit_file 传入 old_text 和 new_text
3. 绝对不要用 write_file 来修改现有文件`;
工具列表、参数格式、调用协议——这些全部由 LangChain 根据 Zod schema 自动生成。提示词只需要关注行为规则,而不是格式规范。
但这里有一个血泪教训:小模型对提示词的理解比你想象的更字面。
我最初在规则里写了一句"执行命令前要考虑安全性"。结果 qwen2.5 把这理解成了"不要执行命令"——每次用户要求执行 npm install,模型都会拒绝并建议用户"自己去终端手动执行"。
修复方式是把模糊的"考虑安全性"改成确定的"直接调用 run_command,安全确认由系统自动处理,你不需要担心安全问题"。对小模型来说,消除歧义比优雅措辞重要一万倍。
当 Agent 拥有了 run_command 这种能力时,一个严肃的问题浮出水面:谁来决定 Agent 能做什么?
参考 Claude Code 的做法,我设计了一个两层权限机制:
第一层:配置文件白名单
用户可以在项目根目录创建 .mini-cc.json,配置允许自动执行的命令前缀:
{
"allowedCommands": ["ls", "cat", "git status", "git diff"]
}
匹配逻辑是前缀匹配——白名单里的 "ls" 可以匹配 ls -la、ls src/。这样既安全又灵活。
第二层:交互式确认
不在白名单中的命令,工具会暂停 Agent 的执行,在终端弹出确认提示:
️ 即将执行命令: npm install
是否允许执行?(y/N)
这里有个有趣的工程问题:确认提示需要和 ora spinner 协调。Agent 在执行工具时,CLI 层正在显示一个旋转的 loading 动画。如果直接弹出 readline 提示,spinner 会不断刷新覆盖提示文本,用户根本看不到也输入不了。
解决方式是通过一个共享的 spinner 引用,在弹出确认前暂停 spinner,确认后恢复:
export function confirmCommand(command: string): Promise<boolean> {
return new Promise((resolve) => {
// 暂停 spinner
const wasSpinning = activeSpinner?.isSpinning ?? false;
if (wasSpinning) activeSpinner.stop();
rlInstance.question("是否允许执行?(y/N) ", (answer) => {
const allowed = answer.trim().toLowerCase() === "y";
// 恢复 spinner
if (wasSpinning) activeSpinner.start("执行中...");
resolve(allowed);
});
});
}
这个设计体现了一个原则:Agent 的能力边界不应该由 Agent 自己决定,而应该由系统来强制执行。模型可以请求做任何事,但最终执行与否由系统的权限层把关。这比在提示词里写"不要执行危险命令"靠谱一万倍——因为提示词是可以被 prompt injection 突破的,但代码级别的权限检查不会。
在自己实现了两个版本之后,回过头来看 Claude Code 这样的产品级系统,我对它的设计有了更深的理解(和合理的推测)。
Claude Code 使用的是 Claude 模型的原生 tool use 能力(和我的第二版一样的思路),但它的工具集远比 mini-cc 丰富:
edit_file 思路一致,要求 old_string 在文件中唯一一个值得注意的设计:Claude Code 把"搜索文件"和"搜索代码内容"做成了独立工具(Glob、Grep),而不是让模型用 run_command 去跑 find 和 grep。这样做的好处是每个工具的参数和行为都是确定的,模型不需要记住 grep -r 的各种 flag。
Claude Code 最让我佩服的是它的上下文管理能力。我的 mini-cc 用 MemorySaver 把所有消息都存在内存里,对话长了就会超出模型的上下文窗口。
Claude Code 似乎做了几件事:
这种"分治"思路是处理长任务的关键。一个复杂的重构任务可能涉及几十个文件,全部塞进一个上下文窗口是不可能的。但如果拆成"先搜索→确定范围→逐个文件修改"的步骤,每步的上下文量就可控了。
| 方面 | mini-cc | Claude Code(推测) |
|---|---|---|
| 模型 | 本地 Ollama(qwen2.5 7B) | Claude 4 系列(远端 API) |
| 工具数量 | 6 个 | 10+ 个 |
| 权限系统 | 配置文件白名单 + 交互确认 | 多级权限(全自动/确认/拒绝) |
| 上下文管理 | MemorySaver(全量存储) | 自动压缩 + 按需加载 |
| 文件编辑 | 基础的字符串替换 | 更智能的 diff 策略 |
| 子任务 | 不支持 | Sub-agent 隔离执行 |
差距是明显的,但核心思路是一致的:Agent = 推理循环 + 工具系统 + 状态管理。mini-cc 是这个公式的最小实现,Claude Code 是这个公式的产品级实现。它们之间的差距不在于"思路不同",而在于"工程深度不同"。
两个版本做下来,我对 AI Agent 有了一些自己的理解。
很多人以为 AI Agent 就是一个写得很好的提示词。确实,提示词很重要,但它只是 Agent 的"灵魂",不是 Agent 的全部。一个可用的 Agent 还需要:
把概念剥干净,一个 Agent 就是三个东西的组合:
这三个东西的质量决定了 Agent 的上限。模型能力决定了推理的质量,工具系统决定了行动的边界,状态管理决定了长任务的可行性。
从第一版到第二版,我最大的感受是:Agent 的难点不在于"让模型更聪明",而在于"让系统更可靠"。
模型会幻觉、会犯错、会输出格式不对的内容——这些都不可怕,可怕的是你的系统没有能力去检测和纠正这些错误。edit_file 工具的唯一性校验、命令执行的权限系统、工具结果的错误反馈——这些工程上的"小细节",加起来才构成了一个可用的 Agent。
一个写得再好的提示词,在 prompt injection 面前都是纸糊的。但一个权限系统硬编码的 isCommandAllowed() 检查,任何提示词注入都绕不过去。
这就是工程化的价值。
mini-cc 目前是一个能跑的最小 Agent,但离一个真正好用的编程助手还有很长的路。以下是我思考的几个方向:
当前的架构是单 Agent 模式——一个模型负责所有事情。但复杂任务往往需要不同角色的协作:
LangGraph 天然支持多 Agent 编排——每个 Agent 是图中的一个子图,通过消息传递协作。这是 mini-cc 下一步最值得做的事情。
目前的 Agent 是"走一步看一步"的——拿到用户请求,直接开始行动。但复杂任务需要先规划再执行。
比如用户说"把这个项目从 JavaScript 迁移到 TypeScript",Agent 应该先列出所有需要改的文件、确定改动顺序、考虑类型定义的依赖关系,然后再逐步执行。
这需要在 ReAct 循环之外加一个 Planning 阶段,让模型先输出一个执行计划,用户确认后再执行。
当前的 MemorySaver 把所有消息存在内存里,对话长了就会超出模型的上下文窗口。需要更智能的策略:
当前的 Agent 没有自我反思能力——如果工具执行失败,它只是看到错误信息然后尝试另一个方案。但更好的做法是在失败后做一次显式的反思:
这可以通过在 LangGraph 的状态图中加一个"反思节点"来实现——当工具执行失败时,先进入反思节点做分析,再回到行动节点。
整个项目做下来,我最大的收获不是代码本身,而是对 AI Agent 的认知从"黑盒"变成了"透明"。
Claude Code 不再是一个神秘的产品——它就是一个精心设计的状态机,配合一套可靠的工具系统和一个强大的推理模型。它的每一个能力,都对应着架构中的某个模块。它的每一个限制,也都能从架构设计中找到原因。
如果你也对 AI Agent 感兴趣,我的建议是:别只看论文和框架文档,自己写一个。从最简单的 ReAct 循环开始,踩完所有的坑,你就真正理解了。
项目开源地址:github.com/Sunny-117/m…
如果这篇文章对你有帮助,欢迎 star