面向 Python 服务
是你的代码,还是在等?
别的 agent 都能告诉你某个函数变慢了。这一个会告诉你是变慢的哪一半 —— 是正在计算的代码,还是它花在等一把锁、等一个连接池位置、或者等一个拿着 GIL 不放的 C 扩展上的时间。这两者需要完全相反的修法,而在后者发生的整个过程中,其他所有按次统计的数字都仍然是对的 —— 这正是别的东西抓不到它的原因。
它测量什么
- 每次调用的 CPU
- time.thread_time() 是按线程算的,读一次大约 100 纳秒,而一个同步 span 在它的整个时长内独占自己的线程 —— 所以这是一个精确的数字,而不是把整个进程的时钟在同时在跑的东西之间摊出来的一份。
- 每次调用的等待
- 一个函数自身时间里剩下的那部分:一把比以前多锁住了一些工作的锁、一个没有空位的连接池、一个拿了 GIL 又不还的 C 扩展。它对这里其他所有测量都是隐形的,因为代码并没有变慢 —— 它在排队。
- 每一条 psycopg 查询
- 2 和 3 两个版本,打补丁的位置是游标而不是连接,所以架在 psycopg 之上的 SQLAlchemy 也会被测到。返回行数、列数、响应大小,以及规范化后的语句。
- 哪个函数发出了哪条查询
- 一条查询会记在它上面那个函数头上,所以 N+1 会落在带着循环的那个函数上,而不是摊在一条单独看一直都没问题的查询上。
- 你的 HTTP 路由,按它们被写出来的样子
- Flask 按匹配到的 url_rule,Django 按 urls.py 里写的那个路由,ASGI 或 WSGI 的东西则按你包住的对象。于是动态里显示的是 GET /users/<int:user_id>/orders,而不是每个用户 ID 一个操作。
它能找到什么
这里的每一项都会和上一个版本作比较,所以发现指向的是那次改动,而不是某一天。
你自己的代码变慢了,而且拆成了两半
这条发现会告诉你该伸手去拿哪一种修法。计算时间涨了、等待没涨:去看函数内部。等待涨了、CPU 没动:去看连接池、锁,或者这个进程现在在和谁共享线程。
上个版本还没有的 N+1
一个原本每次请求发一条查询的函数,现在发二十条。每一条都很快、索引也都对,所以没有任何耗时会变化到让人察觉 —— 变的只有次数。
一条开始读整张表的查询
每次调用的行数从 30 变成了 30,000。在一个数据量像开发环境、又已经热起来的数据库上,这几乎不占时间;等到后面堆了一年的数据,它就会把生产压垮。
某个东西现在被放在循环里调用
每次调用没有变化 —— 同样的速度、同样的数据 —— 但被调用了几百次,而以前只调用一次。
一条悄悄什么都不返回的查询
成功了,没有抛异常,也不慢,只是从原本有行变成了空。这类服务出问题的方式,多数都是让数字变小而不是变大。
错误,按它们的来处归类
按代码位置而不是按报错文案归类,所以一个 bug 就是一条记录,不管它会被格式化成多少种不同的字符串。
你的动态里会出现什么
不是一块需要你去读的仪表盘。每条发现一张卡片,上面有数字、有改变了它们的那个版本,还有你的编码助手写出修复所需要的证据。
这是 apps/demo-python 故意带上的两个回归,对着它的 seed 脚本创建的数据集 —— 大约 1,000 个用户和 30,000 条订单。你可以跑起来,看着它们出现。
orders.get_user_orders一次重构把 WHERE 子句弄丢了,于是每次调用都把整张表拉下来,再用 Python 过滤。时钟几乎没动;行数就是全部的信号。
自版本 v2 起,对比 v1
orders.enrich_orders一次批量查找变成了逐个订单查。每条查询单独看都很快,索引也都对,而这恰恰就是它能通过评审和 staging 的原因。
自版本 v2 起,对比 v1
如何安装
把它交给你已经开着的编码助手。它会判断这个项目是 Flask、Django、FastAPI 还是一个裸的 WSGI callable,把中间件放到对的位置,并给服务层打上标记 —— 接着在同一段对话里,把找到的问题修掉。
claude mcp add sixty \
-e SIXTY_API_KEY=sixty_sk_… \
-e SIXTY_ENDPOINT=https://ingest.sixty.sh \
-- npx -y @sixty-sh/mcp
# or by hand:
pip install sixty-sh
# wsgi.py, asgi.py or main.py — before any connection is opened
import sixty
sixty.init()
# then one of these, whichever this project is:
# Flask instrument_flask(app)
# Django SixtyMiddleware, first in MIDDLEWARE
# ASGI SixtyASGIMiddleware
# WSGI SixtyMiddleware around the callable
# and the code between the view and the database
@sixty.trace
def get_user_orders(user_id):
...版本标识会自动从 Vercel、Render、Railway、Fly、Heroku 或 GitHub Actions 拿到,其他地方用 SIXTY_RELEASE 指定。第一次比较会在你下一次部署之后到来,因为一个版本没有可比的对象。
它不做什么
- 一个跨越 await 的 span 会和事件循环期间跑的其他东西共享线程,所以它上报的是「没有 CPU」,而不是一个被夸大的数字。一个 asyncio 服务能拿到按同步函数和按查询统计的 CPU,但拿不到按请求统计的 —— 这是诚实的答案,而不是一个听起来合理的答案。
- 不采集查询计划。Postgres 就在那儿,EXPLAIN 也能用;只是这个 agent 目前还不会发出它,所以「它不再走索引了」会在 Node 和 Ruby 上出现,在这里不会。
- 只有 psycopg 被埋了点。架在 psycopg 上的 SQLAlchemy 能被测到,因为下面的游标被测到了 —— asyncpg 和 MySQL 的驱动完全测不到。
- 在 gunicorn 或 uwsgi 下,每个 worker 各自上报,由收集端合并。这是对的,但当你在读一个描述单个进程的数字时,值得知道这一点。
- 这里没有任何东西会替你标注函数。Node 靠一个构建期转换拿到这件事,而 Python 没有等价的钩子,所以那个装饰器或者 instrument_module 是你要走的一步 —— 跳过它,动态里就只剩下路由和查询,中间什么都没有。
如果你是因为下面某一条来的
看看你的上一次改动做了什么。
试试看!在做别的东西?可以看看 Node 服务、Go 服务、Ruby 和 Rails 应用 —— 那些页面不一样,我们能看到的东西也不一样。