CLI调用Claude做产品级服务?我试过,翻车了
去年我接过一个活,把Claude Code CLI包成HTTP接口给上层业务调用,subprocess起进程、stdin塞prompt、stdout收文本,简单粗暴。第一周跑得欢,第二周产品提需求:能不能记住上一次问的内容?能不能限定只读?能不能输出JSON?我一一在CLI外面糊补丁,越糊越脏,最后代码长得像一碗意大利面。后来Anthropic出了Agent SDK,我把那堆subprocess代码全删了,三百行Python搞定。
这一篇就讲怎么用Agent SDK把Claude塞进Python Web服务。书里对应第8章,M7里程碑------控制粒度演进的终点:CLAUDE.md(声明式)→ Skills(半声明式)→ Hooks(事件式)→ Agent SDK(编程式)。前三层还在配置范畴,到SDK这一层,Harness本身被当成一个库来调用。
从工具到组件:SDK的定位
CLI是"工具",SDK是"组件"。工具靠shell调用、靠stdin/stdout通信;组件靠import、靠对象方法通信。前者面向人,后者面向代码。
bash
# Python
pip install claude-agent-sdk
# TypeScript/Node.js
npm install @anthropic-ai/claude-agent-sdk
两个语言版本API基本对齐,下面以Python为主。
Quick Start:五分钟跑起来
python
import asyncio
from claude_agent_sdk import query, ClaudeAgentOptions
async def analyze_code():
options = ClaudeAgentOptions(
max_turns=5,
allowed_tools=["Read", "Grep", "Glob"],
system_prompt="你是一名代码架构分析师。",
)
async for message in query(
prompt="分析 src/auth/ 目录的实现架构",
options=options
):
if message.type == "assistant":
for block in message.content:
if hasattr(block, 'text'):
print(block.text, end="", flush=True)
elif message.type == "result":
print(f"\n\n完成。费用:${message.total_cost_usd:.4f}")
asyncio.run(analyze_code())
query()是异步生成器,吐出来的不是一坨字符串,是结构化消息。TS版本几乎一模一样,把async for换成for await、把hasattr换成'text' in block即可。
等一下,这里我漏说一个前提------query()吐的消息不止两种,一共四类,搞不清楚状态机后面会迷糊。
四种消息类型:对话状态机
json
// 1. system_init:会话初始化,给 session_id
{
"type": "system", "subtype": "init",
"session_id": "550e8400-...",
"model": "claude-sonnet-4-6",
"tools": ["Read", "Grep", "Glob"]
}
// 2. assistant:Claude 的响应,既能有文本又能有 tool_use
{
"type": "assistant",
"message": { "role": "assistant",
"content": [
{"type": "text", "text": "让我先看看目录结构..."},
{"type": "tool_use", "id": "toolu_xxx", "name": "Glob",
"input": {"pattern": "src/auth/**/*"}}
]}
}
// 3. user:工具执行结果回灌
{
"type": "user",
"message": { "role": "user",
"content": [{"type": "tool_result", "tool_use_id": "toolu_xxx",
"content": "src/auth/\n├── login.ts\n├── session.ts"}]}
}
// 4. result:任务完成,带成本/耗时/session_id
system_init给session_id,assistant里既能有文本又能有tool_use,user是工具结果回灌,result收尾给费用和元数据。我第一次写的时候把result当成了"最终答案",漏掉了assistant流里也可能有最终文本,结果输出残缺------记住,最终文本在assistant流里,result只是元数据。
ClaudeAgentOptions:精细控制从这开始
CLI时代我想要的控制项,这里都有:
python
from claude_agent_sdk import ClaudeAgentOptions
options = ClaudeAgentOptions(
model="claude-sonnet-4-6",
max_turns=10,
max_budget_usd=1.0, # 成本上限,超了就停
allowed_tools=["Read", "Grep", "Glob", "Write"],
disallowed_tools=["Bash"],
permission_mode="default", # default / acceptEdits / plan / bypassPermissions
system_prompt="你是一名高级代码审查员。",
append_system_prompt="务必检查 SQL 注入漏洞。", # 追加不覆盖
cwd="/path/to/project",
env={"PROJECT_NAME": "MyApp"},
resume="session-id-to-resume", # 续接会话
output_format={"type": "json_schema", "schema": my_schema},
mcp_servers=[{"name": "db", "command": "python", "args": ["./db_server.py"]}],
)
max_budget_usd这条我必须有------给客户做项目的时候,没有成本上限的Agent能在测试循环里把预算烧光,亲身经历,一个晚上烧了四十刀。
工具权限还能做模式匹配,颗粒度到命令参数:
python
options = ClaudeAgentOptions(
allowed_tools=[
"Read", "Grep", "Glob",
"Bash(git diff *)", # 只允许 git diff
"Bash(npm test *)", # 只允许 npm test
"mcp__database__query", # 只允许这个 MCP 工具
]
)
Bash(git diff *)这种写法比disallowed_tools=["Bash"]然后自己写正则过滤命令行优雅多了------CLI时代我就是这么糊的,这里是SDK原生支持。
session_id:会话延续和分支
python
# 第一轮:分析问题,拿到 session_id
session_id = None
async for message in query(prompt="分析 src/auth 的安全问题", options=options):
if message.type == "system" and message.subtype == "init":
session_id = message.session_id
# 第二轮:在上一轮上下文里继续
resume_options = ClaudeAgentOptions(**options.__dict__, resume=session_id)
async for message in query(
prompt="重点分析你发现的第一个 SQL 注入风险",
options=resume_options
):
...
要分支探索两个方向,加fork_session=True:
python
options_fork = ClaudeAgentOptions(
**options.__dict__, resume=session_id, fork_session=True
)
# 方向 A:重构为微服务
async for message in query(prompt="如果重构为微服务,需要改哪些?", options=options_fork):
...
# 方向 B:原架构加固,同一个 session_id 再 fork
async for message in query(prompt="如果保持现有架构,怎么加固安全?", options=options_fork):
...
fork_session不污染原会话------做A/B方案对比的时候特别好用。
@tool 装饰器:自定义工具 + Pydantic
python
from claude_agent_sdk import tool, create_sdk_mcp_server
from pydantic import BaseModel, Field
class DatabaseQueryParams(BaseModel):
table: str = Field(..., description="Table name")
columns: list[str] = Field(default=["*"], description="Columns to select")
where: str | None = Field(default=None, description="WHERE clause")
limit: int = Field(default=100, ge=1, le=1000) # 1到1000之间
@tool(
name="safe_query",
description="Execute a safe, parameterized database query",
parameters=DatabaseQueryParams # 直接传 Pydantic 类
)
async def safe_query(args: DatabaseQueryParams):
# args 已经通过 Pydantic 验证,类型安全
rows = await db.execute(args.table, args.columns, args.where, args.limit)
return {"content": [{"type": "text", "text": json.dumps(rows)}]}
# 用 MCP 服务器承载这些工具
tools_server = create_sdk_mcp_server(
name="app-tools", version="1.0.0",
tools=[safe_query]
)
options = ClaudeAgentOptions(
mcp_servers={"app-tools": tools_server},
allowed_tools=["Read", "Grep", "Glob", "mcp__app-tools__safe_query"]
)
parameters直接传Pydantic类,SDK会自动转成JSON Schema喂给Claude,Claude回传的参数也会被Pydantic校验------limit超1000直接拒。CLI时代我得自己写参数校验,还经常漏边界。
等一下,这里我又漏了一个前提------光有参数校验不够,还得有运行时拦截。
四道安全防线:脱离CLI沙箱也得住
CLI有沙箱兜底,SDK脱离了CLI,得自己搭防线。书上给了四道:
python
from claude_agent_sdk import ClaudeAgentOptions, HookMatcher
# 防线一: PreToolUse Hook------执行前拦截
async def block_dangerous_bash(input_data, tool_use_id, context):
if input_data["tool_name"] != "Bash":
return {}
command = input_data["tool_input"].get("command", "")
dangerous = ["rm -rf", "sudo", "chmod 777", "> /dev/", "mkfs", "dd if="]
for pattern in dangerous:
if pattern in command:
return {"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": f"Blocked: {pattern}"
}}
return {}
# 防线二: can_use_tool------运行时权限检查
async def can_use_tool(tool_name: str, tool_input: dict) -> dict:
if tool_name in ["Write", "Edit"]:
file_path = tool_input.get("file_path", "")
if ".env" in file_path or "secrets" in file_path:
return {"allowed": False, "reason": "Access to sensitive files denied"}
if tool_name == "Bash":
command = tool_input.get("command", "")
if any(cmd in command for cmd in ["curl", "wget", "ssh"]):
return {"allowed": False, "reason": "Network commands not allowed"}
return {"allowed": True}
# 防线三: PostToolUse------执行后审计
async def audit_all_tools(input_data, tool_use_id, context):
import json
from datetime import datetime
entry = {
"timestamp": datetime.now().isoformat(),
"tool": input_data["tool_name"],
"input": input_data["tool_input"],
}
with open("agent-audit.jsonl", "a") as f:
f.write(json.dumps(entry) + "\n")
return {}
options = ClaudeAgentOptions(
permission_mode="acceptEdits", # 防线零: 权限模式
allowed_tools=["Read", "Write", "Edit", "Grep", "Glob"], # 防线一: 工具白名单
can_use_tool=can_use_tool, # 防线二: 运行时检查
hooks={ # 防线三: Hooks 拦截+审计
"PreToolUse": [HookMatcher(matcher="Bash", hooks=[block_dangerous_bash])],
"PostToolUse": [HookMatcher(matcher="*", hooks=[audit_all_tools])],
}
)
四道防线:permission_mode(粗粒度模式)+ allowed_tools(工具白名单)+ can_use_tool(运行时检查)+ PreToolUse/PostToolUse Hooks(事件拦截+审计)。我做生产服务时这四道全开,少一道都睡不着。
结构化输出:强制JSON Schema
python
from pydantic import BaseModel
class SecurityReport(BaseModel):
summary: str
issues: list[dict] # [{severity, file, line, description}]
risk_score: float # 0.0 - 10.0
options = ClaudeAgentOptions(
output_format={
"type": "json_schema",
"schema": SecurityReport.model_json_schema()
},
max_turns=10,
allowed_tools=["Read", "Grep", "Glob"],
)
async for message in query(prompt="对 src/ 进行安全审查", options=options):
if message.type == "result" and message.structured_output:
report = SecurityReport.model_validate(message.structured_output)
print(f"风险评分:{report.risk_score}")
for issue in report.issues:
print(f" [{issue['severity']}] {issue['file']}:{issue.get('line', '?')}")
message.structured_output是SDK帮你validate好的dict,再用Pydantic包一层就是类型安全对象。CLI时代我用正则解析Claude的Markdown输出,写到崩溃。
完整Web服务:FastAPI + SSE
把上面这些拼起来,一个能跑的代码分析服务:
python
#!/usr/bin/env python3
"""代码分析 Agent 服务------一个可运行的完整示例"""
import asyncio, json
from claude_agent_sdk import query, ClaudeAgentOptions
async def analyze_codebase(directory: str, focus: str = "general"):
focus_prompts = {
"security": "专注于安全漏洞:SQL 注入、XSS、敏感信息硬编码、权限控制。",
"performance": "专注于性能问题:N+1 查询、内存泄漏、缺少缓存。",
"quality": "专注于代码质量:命名规范、DRY 原则、复杂度、测试覆盖。",
"general": "全面分析:安全、性能、质量、架构。"
}
options = ClaudeAgentOptions(
model="claude-sonnet-4-6",
max_turns=15,
max_budget_usd=0.50,
allowed_tools=["Read", "Grep", "Glob"],
permission_mode="plan", # 只读模式
cwd=directory,
append_system_prompt=focus_prompts.get(focus, focus_prompts["general"]),
)
output_text, tools_used, metadata = [], [], {}
async for message in query(
prompt=f"分析当前项目的代码。输出 Markdown 格式的分析报告。",
options=options,
):
if message.type == "assistant":
for block in message.content:
if hasattr(block, 'text'):
output_text.append(block.text)
elif hasattr(block, 'name'):
tools_used.append(block.name)
elif message.type == "result":
metadata = {
"session_id": message.session_id,
"cost_usd": message.total_cost_usd,
"turns": message.num_turns,
"duration_ms": message.duration_ms,
"success": not message.is_error,
}
return {"report": "\n".join(output_text), "tools_used": tools_used, "metadata": metadata}
包成FastAPI接口,加SSE流式输出:
python
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
app = FastAPI()
@app.post("/api/analyze")
async def analyze(request: AnalyzeRequest):
async def event_stream():
async for message in query(prompt=request.prompt, options=options):
if message.type == "assistant":
for block in message.content:
if hasattr(block, 'text'):
yield f"data: {json.dumps({'type': 'text', 'content': block.text})}\n\n"
elif message.type == "result":
yield f"data: {json.dumps({'type': 'done', 'cost': message.total_cost_usd})}\n\n"
return StreamingResponse(event_stream(), media_type="text/event-stream")
StreamingResponse把async for的每一段文本立刻推给前端,用户体验比"等三十秒再一次性返回"好太多。
顺便一提,我自己的雷达鸭App(华为应用市场+微信小程序,收录中国一人公司赚钱案例)的AI分析接口,也是用Agent SDK包成FastAPI服务挂在内网,前端Uni-app+ArkTS直接SSE消费,比之前subprocess那套稳定多了。
下一版我想要什么
我对下一版SDK有两个期待:一是can_use_tool支持异步流式审批------现在一次只能返回allow/deny,复杂策略要写一堆if-else;二是output_format能原生支持流式部分JSON------现在结构化输出必须等result才能拿到完整对象,长报告场景下用户得干等。
Agent SDK把Harness从"开发者用的工具"变成了"产品里的组件",控制粒度到命令参数、会话分支、运行时拦截这一层。CLI的活,本来就不该让产品服务去干。
关于作者:雷达鸭App独立开发者,10+年软件开发经验,软件设计师,人工智能应用工程师,专注鸿蒙ArkTS+Web前端,探索AI自动化。
版权声明:本文基于《Claude Code 实战:Harness 工程之道》(黄佳 著)第8章内容整理,原书内容版权归原作者所有。本文采用 MIT 协议发布,转载请保留本声明。