| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889 |
- <!doctype html><html lang="zh"><head><meta charset="utf-8">
- <meta name="viewport" content="width=device-width,initial-scale=1">
- <title>工具调用范式③:claude-code 原生车道(把工具调用外包给运行时)</title><style>
- :root{--fg:#1a1a1a;--bg:#fff;--mut:#666;--line:#e3e3e3;--accent:#3b5bdb;
- --codebg:#f6f8fa;--warn:#b00020;--okbg:#eef4ff;--formalbg:#f3f0ff;--conceptbg:#fbfbf9;}
- *{box-sizing:border-box}
- body{font:15px/1.65 -apple-system,"PingFang SC",Segoe UI,Roboto,sans-serif;
- color:var(--fg);background:var(--bg);margin:0;padding:0}
- a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}
- .wrap{max-width:1180px;margin:0 auto;padding:28px 22px 80px}
- header.top{border-bottom:1px solid var(--line);padding-bottom:14px;margin-bottom:22px}
- header.top h1{margin:0 0 4px;font-size:20px}
- .sub{color:var(--mut);font-size:13px}
- .crumbs{font-size:13px;margin-bottom:18px}.crumbs a{color:var(--mut)}
- .badge{display:inline-block;font-size:12px;background:var(--okbg);color:var(--accent);
- border-radius:4px;padding:1px 8px;margin-right:6px}
- h2.ctitle{font-size:19px;margin:6px 0 4px}
- .keypoint{background:#fff8e6;border-left:3px solid #e6a700;padding:10px 14px;
- border-radius:4px;margin:14px 0;font-size:14px}
- .cols{display:grid;grid-template-columns:1fr 1.5fr 1fr;gap:16px;margin:22px 0}
- .col{border:1px solid var(--line);border-radius:8px;padding:14px 16px;min-width:0}
- .col h3{margin:0 0 10px;font-size:13px;letter-spacing:.04em;text-transform:uppercase;color:var(--mut)}
- .col.concept{background:var(--conceptbg)}
- .col.formal{background:var(--formalbg)}
- .col p{margin:0 0 10px;white-space:pre-wrap;font-size:14px}
- .anchor{margin:0 0 16px}
- .anchor .meta{font-size:12px;color:var(--mut);margin-bottom:4px}
- .anchor .meta code{background:var(--codebg);padding:1px 5px;border-radius:3px}
- pre{background:var(--codebg);border:1px solid var(--line);border-radius:6px;
- overflow:auto;margin:0 0 6px;padding:10px 0;font-size:12px;line-height:1.5}
- pre code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;display:block}
- .ln{display:flex}.ln .n{color:#aaa;text-align:right;width:46px;padding:0 10px;
- user-select:none;flex:none}.ln .c{white-space:pre;padding-right:14px}
- .note{font-size:12.5px;color:var(--mut);margin:0 0 14px}
- .warn{color:var(--warn);background:#fff0f0;border:1px solid #ffd0d0;border-radius:6px;
- padding:8px 12px;font-size:13px}
- .kv{font-size:13px}.kv code{background:var(--codebg);padding:1px 5px;border-radius:3px}
- .formal .term{font-family:ui-monospace,Menlo,monospace;background:#fff;border:1px solid var(--line);
- border-radius:5px;padding:8px 10px;font-size:13px;margin-bottom:8px;word-break:break-word}
- .effect{font-family:ui-monospace,Menlo,monospace;color:#7048e8;font-size:13px;margin-bottom:8px}
- .trace{border:1px solid var(--line);border-radius:8px;padding:14px 16px;margin:22px 0;background:#fafafa}
- .trace h3{margin:0 0 10px;font-size:14px}
- .stat{display:inline-block;margin:0 18px 6px 0;font-size:13px}
- .stat b{font-size:17px;color:var(--accent)}
- .toolchip{display:inline-block;background:#eef;border:1px solid #dde;border-radius:12px;
- padding:1px 9px;margin:0 6px 6px 0;font-size:12px}
- .takeaway{background:var(--okbg);border-radius:8px;padding:14px 18px;margin:22px 0;
- white-space:pre-wrap;font-size:14px}
- .rel a{margin-right:14px;font-size:13px}
- table.idx{border-collapse:collapse;width:100%;margin-top:8px}
- table.idx td{border-bottom:1px solid var(--line);padding:9px 8px;vertical-align:top}
- table.idx .wk{color:var(--mut);font-size:13px;width:64px;white-space:nowrap}
- .modhdr{margin:26px 0 4px;font-size:15px;color:var(--accent)}
- footer{margin-top:50px;color:var(--mut);font-size:12px;border-top:1px solid var(--line);padding-top:14px}
- @media(max-width:900px){.cols{grid-template-columns:1fr}}
- </style></head><body><div class="wrap">
- <div class="crumbs"><a href="index.html">← 对照卡索引</a></div>
- <header class="top">
- <span class="badge">模块 A</span><span class="badge">第 2 周</span>
- <span class="badge">tool-claude-code-native</span>
- <h2 class="ctitle">工具调用范式③:claude-code 原生车道(把工具调用外包给运行时)</h2></header>
- <div class="keypoint">🎯 单体工作区助手套进 react-over-text 全是成本没收益(实测 76 调用 0 执行); 正解是开第二条车道:直接原生跑 claude-code,native 工具开、流式事件映射回平台 SSE。
- </div>
- <div class="cols">
- <div class="col concept"><h3>📖 教科书概念</h3><p>第三种范式不在平台内实现工具循环,而是把整段任务**外包**给一个已经内建了
- 工具执行能力的智能体运行时(这里是 claude-code CLI)。平台只负责:起进程、
- 喂 prompt、把运行时吐出的事件流翻译成自己的 SSE,以及划定它能动的目录与工具集。
- 这对应工程上"何时自己写 agent loop、何时复用现成 agent"的取舍:当任务是
- "在一个文件夹里自由读写、跑命令"的单体助手时,直接调一次成熟运行时,往往优于
- 把它套进自家 react-over-text 封装(那是给"必须派活给子智能体"的 orchestrator 设计的)。
- </p></div>
- <div class="col code"><h3>💻 平台源码落点</h3><div class="anchor"><div class="meta"><code>lambdagent/src/lambdagent/providers/claude_code_native.py</code> · <b>run_native</b> · L127-210</div><pre><code><div class="ln"><span class="n">127</span><span class="c">def run_native(prompt: str, *, cwd: str, model: str = "sonnet",</span></div><div class="ln"><span class="n">128</span><span class="c"> system_append: str = "", session_id: Optional[str] = None,</span></div><div class="ln"><span class="n">129</span><span class="c"> on_event: Optional[Callable[[dict], None]] = None,</span></div><div class="ln"><span class="n">130</span><span class="c"> config_extra: Optional[dict] = None,</span></div><div class="ln"><span class="n">131</span><span class="c"> cancel=None) -> NativeResult:</span></div><div class="ln"><span class="n">132</span><span class="c"> """在 cwd 里原生跑一次 claude-code,流式回调事件,返回最终结果。</span></div><div class="ln"><span class="n">133</span><span class="c"></span></div><div class="ln"><span class="n">134</span><span class="c"> auth(401)→ 抛 ProviderError(_AUTH_HINT)。卡死/超时 → 抛 ProviderError(retryable)。</span></div><div class="ln"><span class="n">135</span><span class="c"> """</span></div><div class="ln"><span class="n">136</span><span class="c"> extra = config_extra or {}</span></div><div class="ln"><span class="n">137</span><span class="c"> claude_bin = _find_working_claude(extra.get("claude_bin", "claude")) or "claude"</span></div><div class="ln"><span class="n">138</span><span class="c"> allowed = extra.get("native_allowed_tools") or _DEFAULT_ALLOWED_TOOLS</span></div><div class="ln"><span class="n">139</span><span class="c"> strict_mcp = not bool(extra.get("native_allow_mcp", False))</span></div><div class="ln"><span class="n">140</span><span class="c"> cmd = build_cmd(claude_bin, prompt, cwd=cwd, model=model,</span></div><div class="ln"><span class="n">141</span><span class="c"> system_append=system_append, session_id=session_id,</span></div><div class="ln"><span class="n">142</span><span class="c"> allowed_tools=allowed, strict_mcp=strict_mcp)</span></div><div class="ln"><span class="n">143</span><span class="c"></span></div><div class="ln"><span class="n">144</span><span class="c"> first_byte_timeout = float(extra.get("native_first_byte_timeout", _DEFAULT_FIRST_BYTE_TIMEOUT_S))</span></div><div class="ln"><span class="n">145</span><span class="c"> idle_timeout = float(extra.get("native_idle_timeout", _DEFAULT_IDLE_TIMEOUT_S))</span></div><div class="ln"><span class="n">146</span><span class="c"> hard_timeout = float(extra.get("native_hard_timeout", _DEFAULT_HARD_TIMEOUT_S))</span></div><div class="ln"><span class="n">147</span><span class="c"></span></div><div class="ln"><span class="n">148</span><span class="c"> logger.info("claude-code NATIVE spawn: cwd=%s model=%s resume=%s tools=%s strict_mcp=%s",</span></div><div class="ln"><span class="n">149</span><span class="c"> cwd, model, bool(session_id), ",".join(allowed), strict_mcp)</span></div><div class="ln"><span class="n">150</span><span class="c"></span></div><div class="ln"><span class="n">151</span><span class="c"> result = NativeResult()</span></div><div class="ln"><span class="n">152</span><span class="c"> t0 = time.time()</span></div><div class="ln"><span class="n">153</span><span class="c"> proc = subprocess.Popen(</span></div><div class="ln"><span class="n">154</span><span class="c"> cmd, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,</span></div><div class="ln"><span class="n">155</span><span class="c"> bufsize=0, # 二进制非阻塞读,不用行缓冲(bufsize=1 在 binary 模式会告警)</span></div><div class="ln"><span class="n">156</span><span class="c"> )</span></div><div class="ln"><span class="n">157</span><span class="c"> buf = b""</span></div><div class="ln"><span class="n">158</span><span class="c"> last_byte_at: Optional[float] = None</span></div><div class="ln"><span class="n">159</span><span class="c"> err_chunks: List[bytes] = []</span></div><div class="ln"><span class="n">160</span><span class="c"> saw_result = False</span></div><div class="ln"><span class="n">161</span><span class="c"></span></div><div class="ln"><span class="n">162</span><span class="c"> def _process_line(line: str):</span></div><div class="ln"><span class="n">163</span><span class="c"> nonlocal saw_result</span></div><div class="ln"><span class="n">164</span><span class="c"> line = line.strip()</span></div><div class="ln"><span class="n">165</span><span class="c"> if not line:</span></div><div class="ln"><span class="n">166</span><span class="c"> return</span></div><div class="ln"><span class="n">167</span><span class="c"> try:</span></div><div class="ln"><span class="n">168</span><span class="c"> obj = json.loads(line)</span></div><div class="ln"><span class="n">169</span><span class="c"> except json.JSONDecodeError:</span></div><div class="ln"><span class="n">170</span><span class="c"> return</span></div><div class="ln"><span class="n">171</span><span class="c"> _handle_event(obj, on_event, result)</span></div><div class="ln"><span class="n">172</span><span class="c"> if obj.get("type") == "result":</span></div><div class="ln"><span class="n">173</span><span class="c"> saw_result = True</span></div><div class="ln"><span class="n">174</span><span class="c"></span></div><div class="ln"><span class="n">175</span><span class="c"> try:</span></div><div class="ln"><span class="n">176</span><span class="c"> import fcntl</span></div><div class="ln"><span class="n">177</span><span class="c"> for f in (proc.stdout, proc.stderr):</span></div><div class="ln"><span class="n">178</span><span class="c"> fl = fcntl.fcntl(f, fcntl.F_GETFL)</span></div><div class="ln"><span class="n">179</span><span class="c"> fcntl.fcntl(f, fcntl.F_SETFL, fl | os.O_NONBLOCK)</span></div><div class="ln"><span class="n">180</span><span class="c"></span></div><div class="ln"><span class="n">181</span><span class="c"> while proc.poll() is None:</span></div><div class="ln"><span class="n">182</span><span class="c"> now = time.time()</span></div><div class="ln"><span class="n">183</span><span class="c"> if cancel is not None and cancel.is_cancelled():</span></div><div class="ln"><span class="n">184</span><span class="c"> proc.kill()</span></div><div class="ln"><span class="n">185</span><span class="c"> raise cancel.CancelledRun()</span></div><div class="ln"><span class="n">186</span><span class="c"> if now - t0 > hard_timeout:</span></div><div class="ln"><span class="n">187</span><span class="c"> proc.kill()</span></div><div class="ln"><span class="n">188</span><span class="c"> raise ProviderError(</span></div><div class="ln"><span class="n">189</span><span class="c"> f"claude-code native hard-timeout after {now-t0:.0f}s", "claude-code",</span></div><div class="ln"><span class="n">190</span><span class="c"> retryable=True)</span></div><div class="ln"><span class="n">191</span><span class="c"> if last_byte_at is None and now - t0 > first_byte_timeout:</span></div><div class="ln"><span class="n">192</span><span class="c"> proc.kill()</span></div><div class="ln"><span class="n">193</span><span class="c"> raise ProviderError(</span></div><div class="ln"><span class="n">194</span><span class="c"> f"claude-code native first-byte-timeout after {now-t0:.0f}s", "claude-code",</span></div><div class="ln"><span class="n">195</span><span class="c"> retryable=True)</span></div><div class="ln"><span class="n">196</span><span class="c"> # idle 只在没有工具在飞时判定。工具在飞(Bash 跑实验/编译可达数分钟,</span></div><div class="ln"><span class="n">197</span><span class="c"> # 期间 stream-json 静默)时不杀,靠上面的 hard_timeout 兜底——否则会把</span></div><div class="ln"><span class="n">198</span><span class="c"> # 正常长跑命令误判成卡死(run_84f6fdb6f11b: idle-timeout after 181s)。</span></div><div class="ln"><span class="n">199</span><span class="c"> if (last_byte_at is not None and result.pending_tools == 0</span></div><div class="ln"><span class="n">200</span><span class="c"> and now - last_byte_at > idle_timeout):</span></div><div class="ln"><span class="n">201</span><span class="c"> proc.kill()</span></div><div class="ln"><span class="n">202</span><span class="c"> raise ProviderError(</span></div><div class="ln"><span class="n">203</span><span class="c"> f"claude-code native idle-timeout after {now-last_byte_at:.0f}s "</span></div><div class="ln"><span class="n">204</span><span class="c"> f"(no tool in flight)", "claude-code", retryable=True)</span></div><div class="ln"><span class="n">205</span><span class="c"></span></div><div class="ln"><span class="n">206</span><span class="c"> ready, _, _ = select.select([proc.stdout, proc.stderr], [], [], 0.5)</span></div><div class="ln"><span class="n">207</span><span class="c"> for fobj in ready:</span></div><div class="ln"><span class="n">208</span><span class="c"> try:</span></div><div class="ln"><span class="n">209</span><span class="c"> chunk = fobj.read()</span></div><div class="ln"><span class="n">210</span><span class="c"> except (BlockingIOError, OSError):</span></div></code></pre><div class="note">claude -p --output-format stream-json --add-dir <文件夹> --allowedTools Read/Write/Edit/Bash/... --strict-mcp-config --permission-mode bypassPermissions;解析 stream-json 事件映射成现有 SSE。
- </div></div><div class="anchor"><div class="meta"><code>lambdagent/src/lambdagent/providers/claude_code_native.py</code> · <b>pending_tools (idle 看门狗)</b> · L44-110</div><pre><code><div class="ln"><span class="n">44</span><span class="c"> output_tokens: int = 0</span></div><div class="ln"><span class="n">45</span><span class="c"> cache_read_tokens: int = 0</span></div><div class="ln"><span class="n">46</span><span class="c"> cache_creation_tokens: int = 0</span></div><div class="ln"><span class="n">47</span><span class="c"> cost_usd: float = 0.0</span></div><div class="ln"><span class="n">48</span><span class="c"> num_turns: int = 0</span></div><div class="ln"><span class="n">49</span><span class="c"> is_error: bool = False</span></div><div class="ln"><span class="n">50</span><span class="c"> trace: List[dict] = field(default_factory=list)</span></div><div class="ln"><span class="n">51</span><span class="c"> pending_tools: int = 0 # 在飞工具数(tool_use 未等到 tool_result);>0 时禁 idle 超时</span></div><div class="ln"><span class="n">52</span><span class="c"></span></div><div class="ln"><span class="n">53</span><span class="c"></span></div><div class="ln"><span class="n">54</span><span class="c">def build_cmd(claude_bin: str, prompt: str, *, cwd: str, model: str,</span></div><div class="ln"><span class="n">55</span><span class="c"> system_append: str = "", session_id: Optional[str] = None,</span></div><div class="ln"><span class="n">56</span><span class="c"> allowed_tools: Optional[List[str]] = None,</span></div><div class="ln"><span class="n">57</span><span class="c"> strict_mcp: bool = True) -> List[str]:</span></div><div class="ln"><span class="n">58</span><span class="c"> """构造 native claude-code 命令。cwd 由调用方在 spawn 时设置。"""</span></div><div class="ln"><span class="n">59</span><span class="c"> cmd = [</span></div><div class="ln"><span class="n">60</span><span class="c"> claude_bin, "-p", prompt,</span></div><div class="ln"><span class="n">61</span><span class="c"> "--output-format", "stream-json", "--verbose",</span></div><div class="ln"><span class="n">62</span><span class="c"> "--model", model,</span></div><div class="ln"><span class="n">63</span><span class="c"> # native 工具限定在工作文件夹(claude-code 的文件操作 root)。</span></div><div class="ln"><span class="n">64</span><span class="c"> "--add-dir", cwd,</span></div><div class="ln"><span class="n">65</span><span class="c"> # -p 非交互:必须 bypass,否则工具卡在权限审批等不到回应。</span></div><div class="ln"><span class="n">66</span><span class="c"> "--permission-mode", "bypassPermissions",</span></div><div class="ln"><span class="n">67</span><span class="c"> "--allowedTools", *(allowed_tools or _DEFAULT_ALLOWED_TOOLS),</span></div><div class="ln"><span class="n">68</span><span class="c"> ]</span></div><div class="ln"><span class="n">69</span><span class="c"> if strict_mcp:</span></div><div class="ln"><span class="n">70</span><span class="c"> cmd.append("--strict-mcp-config") # 隔离用户 MCP(Gmail/Calendar 等)</span></div><div class="ln"><span class="n">71</span><span class="c"> if system_append:</span></div><div class="ln"><span class="n">72</span><span class="c"> cmd += ["--append-system-prompt", system_append]</span></div><div class="ln"><span class="n">73</span><span class="c"> if session_id:</span></div><div class="ln"><span class="n">74</span><span class="c"> cmd += ["--resume", session_id]</span></div><div class="ln"><span class="n">75</span><span class="c"> return cmd</span></div><div class="ln"><span class="n">76</span><span class="c"></span></div><div class="ln"><span class="n">77</span><span class="c"></span></div><div class="ln"><span class="n">78</span><span class="c">def _emit(on_event, etype: str, payload: dict):</span></div><div class="ln"><span class="n">79</span><span class="c"> if on_event is not None:</span></div><div class="ln"><span class="n">80</span><span class="c"> try:</span></div><div class="ln"><span class="n">81</span><span class="c"> on_event({"event": etype, **payload})</span></div><div class="ln"><span class="n">82</span><span class="c"> except Exception as e: # 回调异常不能拖垮主流程</span></div><div class="ln"><span class="n">83</span><span class="c"> logger.warning("native on_event(%s) failed: %s", etype, e)</span></div><div class="ln"><span class="n">84</span><span class="c"></span></div><div class="ln"><span class="n">85</span><span class="c"></span></div><div class="ln"><span class="n">86</span><span class="c">def _handle_event(obj: dict, on_event, result: NativeResult):</span></div><div class="ln"><span class="n">87</span><span class="c"> """把一条 claude stream-json 事件映射成平台 SSE 事件 + 累积结果。"""</span></div><div class="ln"><span class="n">88</span><span class="c"> t = obj.get("type")</span></div><div class="ln"><span class="n">89</span><span class="c"> if t == "system" and obj.get("subtype") == "init":</span></div><div class="ln"><span class="n">90</span><span class="c"> result.session_id = obj.get("session_id") or result.session_id</span></div><div class="ln"><span class="n">91</span><span class="c"> return</span></div><div class="ln"><span class="n">92</span><span class="c"> if t == "assistant":</span></div><div class="ln"><span class="n">93</span><span class="c"> for c in (obj.get("message", {}) or {}).get("content", []) or []:</span></div><div class="ln"><span class="n">94</span><span class="c"> if not isinstance(c, dict):</span></div><div class="ln"><span class="n">95</span><span class="c"> continue</span></div><div class="ln"><span class="n">96</span><span class="c"> if c.get("type") == "text" and c.get("text"):</span></div><div class="ln"><span class="n">97</span><span class="c"> _emit(on_event, "think_chunk", {"text": c["text"]})</span></div><div class="ln"><span class="n">98</span><span class="c"> elif c.get("type") == "tool_use":</span></div><div class="ln"><span class="n">99</span><span class="c"> result.pending_tools += 1 # 工具开飞 → 禁 idle 超时直到结果回来</span></div><div class="ln"><span class="n">100</span><span class="c"> _emit(on_event, "tool_call",</span></div><div class="ln"><span class="n">101</span><span class="c"> {"tool": c.get("name", ""), "input": c.get("input", {})})</span></div><div class="ln"><span class="n">102</span><span class="c"> result.trace.append({"tool": c.get("name", ""),</span></div><div class="ln"><span class="n">103</span><span class="c"> "input": str(c.get("input", ""))[:300]})</span></div><div class="ln"><span class="n">104</span><span class="c"> return</span></div><div class="ln"><span class="n">105</span><span class="c"> if t == "user":</span></div><div class="ln"><span class="n">106</span><span class="c"> for c in (obj.get("message", {}) or {}).get("content", []) or []:</span></div><div class="ln"><span class="n">107</span><span class="c"> if isinstance(c, dict) and c.get("type") == "tool_result":</span></div><div class="ln"><span class="n">108</span><span class="c"> result.pending_tools = max(0, result.pending_tools - 1)</span></div><div class="ln"><span class="n">109</span><span class="c"> out = c.get("content", "")</span></div><div class="ln"><span class="n">110</span><span class="c"> if isinstance(out, list):</span></div></code></pre><div class="note">坑:长命令(实验/pdflatex)期间 stream-json 在工具返回前完全静默,会被 idle 看门狗误杀。修法:跟踪 pending_tools(tool_use+1 / tool_result-1),idle 仅在 pending_tools==0 时生效,在飞工具靠 hard_timeout 兜底。
- </div></div></div>
- <div class="col formal"><h3>∑ 形式化对象</h3><div class="term">外包算子 RunNative(cwd, allowedTools) : Task → (Effects, Result)</div><div class="effect">effect: IO[fs, shell] ⊗ Cap(allowedTools, cwd)</div><p>与①②不同,这里的 effect 不是平台逐步推断的,而是把一整束能力(读写文件、跑 shell) **以能力集 Cap 的形式**授予外部运行时,并用 cwd + allowedTools 划界。形式化关注点 从"每步 effect"转为"授权边界是否正确"——这正好引出 week 8 沙箱/能力的讨论。
- </p></div>
- </div>
- <div class="trace"><h3>🎞 Live Demo</h3><p class="note">对照实验同样进对比台。已 live 验证:真起 claude -p 在临时目录写出文件, 产出 tool_call / tool_result 事件,cost/session 正确。注意路由按 provider 分流—— 切到 qwen/ollama 必须退回 react 车道(claude -p 只认 sonnet/opus/haiku)。
- </p></div>
- <div class="takeaway"><b>学习目标 / Takeaway</b>
- 三范式并排,学生应能在"自己写 loop 的精细控制"与"外包给成熟运行时的省心"之间
- 做权衡判断,并说清:范式③把 effect 推断换成了能力授权边界(Cap),
- 可靠性问题(静默/卡死/路由)随之从"解析正确性"转移到"进程与授权管理"。
- </div>
- <p class="rel"><b>相关对照卡:</b> <a href="tool-react-over-text.html">工具调用范式①:react-over-text(把工具调用当散文手写 JSON)</a> <a href="tool-native-fc.html">工具调用范式②:原生 function-calling(结构化 tool_calls)</a> <a href="sandbox-effect-cap.html">硬沙箱:把'写文件'这一 effect 限制在能力边界内</a></p>
- <footer>对照卡源文件: <code>teaching/cards/03-tool-claude-code-native.yml</code> ·
- 源码 span 由 gen.py 在生成时实时读自仓库</footer>
- </div></body></html>
|